summaryrefslogtreecommitdiffstats
path: root/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /modules
parentInitial commit. (diff)
downloadforgejo-debian.tar.xz
forgejo-debian.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'modules')
-rw-r--r--modules/actions/github.go133
-rw-r--r--modules/actions/github_test.go119
-rw-r--r--modules/actions/log.go224
-rw-r--r--modules/actions/task_state.go123
-rw-r--r--modules/actions/task_state_test.go165
-rw-r--r--modules/actions/workflows.go702
-rw-r--r--modules/actions/workflows_test.go163
-rw-r--r--modules/activitypub/client.go273
-rw-r--r--modules/activitypub/client_test.go138
-rw-r--r--modules/activitypub/main_test.go18
-rw-r--r--modules/activitypub/user_settings.go48
-rw-r--r--modules/activitypub/user_settings_test.go30
-rw-r--r--modules/analyze/code_language.go27
-rw-r--r--modules/analyze/generated.go27
-rw-r--r--modules/analyze/vendor.go13
-rw-r--r--modules/analyze/vendor_test.go41
-rw-r--r--modules/assetfs/layered.go256
-rw-r--r--modules/assetfs/layered_test.go110
-rw-r--r--modules/auth/common.go22
-rw-r--r--modules/auth/openid/discovery_cache.go57
-rw-r--r--modules/auth/openid/discovery_cache_test.go49
-rw-r--r--modules/auth/openid/openid.go37
-rw-r--r--modules/auth/pam/pam.go43
-rw-r--r--modules/auth/pam/pam_stub.go22
-rw-r--r--modules/auth/pam/pam_test.go20
-rw-r--r--modules/auth/password/hash/argon2.go80
-rw-r--r--modules/auth/password/hash/bcrypt.go54
-rw-r--r--modules/auth/password/hash/common.go28
-rw-r--r--modules/auth/password/hash/dummy.go33
-rw-r--r--modules/auth/password/hash/dummy_test.go26
-rw-r--r--modules/auth/password/hash/hash.go189
-rw-r--r--modules/auth/password/hash/hash_test.go191
-rw-r--r--modules/auth/password/hash/pbkdf2.go67
-rw-r--r--modules/auth/password/hash/scrypt.go67
-rw-r--r--modules/auth/password/hash/setting.go76
-rw-r--r--modules/auth/password/hash/setting_test.go38
-rw-r--r--modules/auth/password/password.go136
-rw-r--r--modules/auth/password/password_test.go77
-rw-r--r--modules/auth/password/pwn.go52
-rw-r--r--modules/auth/password/pwn/pwn.go118
-rw-r--r--modules/auth/password/pwn/pwn_test.go51
-rw-r--r--modules/auth/webauthn/webauthn.go77
-rw-r--r--modules/auth/webauthn/webauthn_test.go25
-rw-r--r--modules/avatar/avatar.go139
-rw-r--r--modules/avatar/avatar_test.go137
-rw-r--r--modules/avatar/hash.go28
-rw-r--r--modules/avatar/hash_test.go26
-rw-r--r--modules/avatar/identicon/block.go717
-rw-r--r--modules/avatar/identicon/colors.go134
-rw-r--r--modules/avatar/identicon/identicon.go140
-rw-r--r--modules/avatar/identicon/identicon_test.go39
-rw-r--r--modules/avatar/identicon/polygon.go68
-rw-r--r--modules/avatar/identicon/testdata/.gitignore1
-rw-r--r--modules/avatar/testdata/animated.webpbin0 -> 4934 bytes
-rw-r--r--modules/avatar/testdata/avatar.jpegbin0 -> 521 bytes
-rw-r--r--modules/avatar/testdata/avatar.pngbin0 -> 159 bytes
-rw-r--r--modules/base/base.go9
-rw-r--r--modules/base/natural_sort.go90
-rw-r--r--modules/base/natural_sort_test.go29
-rw-r--r--modules/base/tool.go234
-rw-r--r--modules/base/tool_test.go186
-rw-r--r--modules/cache/cache.go184
-rw-r--r--modules/cache/cache_redis.go161
-rw-r--r--modules/cache/cache_test.go150
-rw-r--r--modules/cache/cache_twoqueue.go208
-rw-r--r--modules/cache/context.go181
-rw-r--r--modules/cache/context_test.go79
-rw-r--r--modules/charset/ambiguous.go59
-rw-r--r--modules/charset/ambiguous/ambiguous.json1
-rw-r--r--modules/charset/ambiguous/generate.go188
-rw-r--r--modules/charset/ambiguous_gen.go836
-rw-r--r--modules/charset/ambiguous_gen_test.go31
-rw-r--r--modules/charset/breakwriter.go43
-rw-r--r--modules/charset/breakwriter_test.go68
-rw-r--r--modules/charset/charset.go211
-rw-r--r--modules/charset/charset_test.go385
-rw-r--r--modules/charset/escape.go58
-rw-r--r--modules/charset/escape_status.go27
-rw-r--r--modules/charset/escape_stream.go289
-rw-r--r--modules/charset/escape_test.go194
-rw-r--r--modules/charset/htmlstream.go200
-rw-r--r--modules/charset/invisible/generate.go121
-rw-r--r--modules/charset/invisible_gen.go36
-rw-r--r--modules/container/filter.go21
-rw-r--r--modules/container/filter_test.go28
-rw-r--r--modules/container/set.go56
-rw-r--r--modules/container/set_test.go36
-rw-r--r--modules/csv/csv.go149
-rw-r--r--modules/csv/csv_test.go590
-rw-r--r--modules/emoji/emoji.go186
-rw-r--r--modules/emoji/emoji_data.go3404
-rw-r--r--modules/emoji/emoji_test.go99
-rw-r--r--modules/eventsource/event.go118
-rw-r--r--modules/eventsource/event_test.go53
-rw-r--r--modules/eventsource/manager.go79
-rw-r--r--modules/eventsource/manager_run.go115
-rw-r--r--modules/eventsource/messenger.go68
-rw-r--r--modules/forgefed/activity.go65
-rw-r--r--modules/forgefed/activity_test.go171
-rw-r--r--modules/forgefed/actor.go218
-rw-r--r--modules/forgefed/actor_test.go225
-rw-r--r--modules/forgefed/forgefed.go52
-rw-r--r--modules/forgefed/nodeinfo.go19
-rw-r--r--modules/forgefed/repository.go111
-rw-r--r--modules/forgefed/repository_test.go145
-rw-r--r--modules/generate/generate.go75
-rw-r--r--modules/generate/generate_test.go35
-rw-r--r--modules/git/README.md3
-rw-r--r--modules/git/batch.go46
-rw-r--r--modules/git/batch_reader.go347
-rw-r--r--modules/git/blame.go211
-rw-r--r--modules/git/blame_sha256_test.go148
-rw-r--r--modules/git/blame_test.go147
-rw-r--r--modules/git/blob.go228
-rw-r--r--modules/git/blob_test.go59
-rw-r--r--modules/git/command.go473
-rw-r--r--modules/git/command_race_test.go38
-rw-r--r--modules/git/command_test.go70
-rw-r--r--modules/git/commit.go590
-rw-r--r--modules/git/commit_info.go178
-rw-r--r--modules/git/commit_info_test.go175
-rw-r--r--modules/git/commit_reader.go110
-rw-r--r--modules/git/commit_sha256_test.go211
-rw-r--r--modules/git/commit_test.go371
-rw-r--r--modules/git/diff.go317
-rw-r--r--modules/git/diff_test.go169
-rw-r--r--modules/git/error.go187
-rw-r--r--modules/git/foreachref/format.go83
-rw-r--r--modules/git/foreachref/format_test.go66
-rw-r--r--modules/git/foreachref/parser.go128
-rw-r--r--modules/git/foreachref/parser_test.go227
-rw-r--r--modules/git/git.go422
-rw-r--r--modules/git/git_test.go96
-rw-r--r--modules/git/grep.go187
-rw-r--r--modules/git/grep_test.go203
-rw-r--r--modules/git/hook.go143
-rw-r--r--modules/git/internal/cmdarg.go9
-rw-r--r--modules/git/last_commit_cache.go159
-rw-r--r--modules/git/log_name_status.go437
-rw-r--r--modules/git/notes.go99
-rw-r--r--modules/git/notes_test.go53
-rw-r--r--modules/git/object_format.go143
-rw-r--r--modules/git/object_id.go114
-rw-r--r--modules/git/object_id_test.go49
-rw-r--r--modules/git/object_signature.go11
-rw-r--r--modules/git/parse.go137
-rw-r--r--modules/git/parse_test.go103
-rw-r--r--modules/git/pipeline/catfile.go108
-rw-r--r--modules/git/pipeline/lfs.go254
-rw-r--r--modules/git/pipeline/namerev.go33
-rw-r--r--modules/git/pipeline/revlist.go86
-rw-r--r--modules/git/pushoptions/pushoptions.go113
-rw-r--r--modules/git/pushoptions/pushoptions_test.go125
-rw-r--r--modules/git/ref.go214
-rw-r--r--modules/git/ref_test.go38
-rw-r--r--modules/git/remote.go39
-rw-r--r--modules/git/repo.go342
-rw-r--r--modules/git/repo_archive.go80
-rw-r--r--modules/git/repo_attribute.go286
-rw-r--r--modules/git/repo_attribute_test.go351
-rw-r--r--modules/git/repo_base.go124
-rw-r--r--modules/git/repo_base_test.go163
-rw-r--r--modules/git/repo_blame.go23
-rw-r--r--modules/git/repo_blob_test.go70
-rw-r--r--modules/git/repo_branch.go349
-rw-r--r--modules/git/repo_branch_test.go197
-rw-r--r--modules/git/repo_commit.go677
-rw-r--r--modules/git/repo_commit_test.go103
-rw-r--r--modules/git/repo_commitgraph.go20
-rw-r--r--modules/git/repo_compare.go345
-rw-r--r--modules/git/repo_compare_test.go164
-rw-r--r--modules/git/repo_gpg.go58
-rw-r--r--modules/git/repo_hook.go14
-rw-r--r--modules/git/repo_index.go159
-rw-r--r--modules/git/repo_language_stats.go251
-rw-r--r--modules/git/repo_language_stats_test.go42
-rw-r--r--modules/git/repo_object.go101
-rw-r--r--modules/git/repo_ref.go157
-rw-r--r--modules/git/repo_ref_test.go56
-rw-r--r--modules/git/repo_stats.go151
-rw-r--r--modules/git/repo_stats_test.go37
-rw-r--r--modules/git/repo_tag.go366
-rw-r--r--modules/git/repo_tag_test.go364
-rw-r--r--modules/git/repo_test.go57
-rw-r--r--modules/git/repo_tree.go156
-rw-r--r--modules/git/signature.go67
-rw-r--r--modules/git/signature_test.go47
-rw-r--r--modules/git/submodule.go119
-rw-r--r--modules/git/submodule_test.go42
-rw-r--r--modules/git/tag.go129
-rw-r--r--modules/git/tag_test.go109
-rw-r--r--modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG3
-rw-r--r--modules/git/tests/repos/language_stats_repo/HEAD1
-rw-r--r--modules/git/tests/repos/language_stats_repo/config5
-rw-r--r--modules/git/tests/repos/language_stats_repo/description1
-rw-r--r--modules/git/tests/repos/language_stats_repo/indexbin0 -> 553 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/info/exclude6
-rw-r--r--modules/git/tests/repos/language_stats_repo/logs/HEAD2
-rw-r--r--modules/git/tests/repos/language_stats_repo/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756cbin0 -> 63 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bbbin0 -> 200 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97bin0 -> 172 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085bin0 -> 116 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03dbin0 -> 73 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640ebin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adcbin0 -> 371 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cdbin0 -> 201 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3bin0 -> 828 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bbbin0 -> 64 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo1_bare/HEAD1
-rw-r--r--modules/git/tests/repos/repo1_bare/config4
-rw-r--r--modules/git/tests/repos/repo1_bare/description1
-rw-r--r--modules/git/tests/repos/repo1_bare/indexbin0 -> 65 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/info/exclude6
-rw-r--r--modules/git/tests/repos/repo1_bare/logs/HEAD2
-rw-r--r--modules/git/tests/repos/repo1_bare/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0bin0 -> 23 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310bin0 -> 31 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6bin0 -> 56 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fbbin0 -> 813 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86bbin0 -> 28 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e31
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32bin0 -> 818 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752ebin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5bin0 -> 23 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64bin0 -> 770 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f91231
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702bin0 -> 134 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8abin0 -> 638 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904bin0 -> 15 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7fbin0 -> 31 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9cabin0 -> 144 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee6583
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80ebin0 -> 32 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd11
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0bin0 -> 176 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631bin0 -> 71 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfebin0 -> 115 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2bin0 -> 153 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4bbin0 -> 770 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653bin0 -> 121 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56bin0 -> 56 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c301852
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13bin0 -> 30 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351bin0 -> 18 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3bin0 -> 176 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24bin0 -> 80 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c4
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612bin0 -> 815 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2dbin0 -> 827 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023dabin0 -> 50 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2bin0 -> 817 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/pulls/1.patch0
-rw-r--r--modules/git/tests/repos/repo1_bare/pulls/2.patch39
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/branch11
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/branch21
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/notes/commits1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/tags/signed-tag1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/tags/test1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/config6
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/description1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/info/refs7
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graphbin0 -> 2048 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmapbin0 -> 710 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idxbin0 -> 2576 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.packbin0 -> 5656 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.revbin0 -> 224 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/packed-refs8
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo2_empty/HEAD1
-rw-r--r--modules/git/tests/repos/repo2_empty/config6
-rw-r--r--modules/git/tests/repos/repo2_empty/description1
-rw-r--r--modules/git/tests/repos/repo2_empty/info/exclude6
-rw-r--r--modules/git/tests/repos/repo2_empty/objects/info/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG1
-rw-r--r--modules/git/tests/repos/repo3_notes/HEAD1
-rw-r--r--modules/git/tests/repos/repo3_notes/config7
-rw-r--r--modules/git/tests/repos/repo3_notes/description1
-rw-r--r--modules/git/tests/repos/repo3_notes/indexbin0 -> 145 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/logs/HEAD2
-rw-r--r--modules/git/tests/repos/repo3_notes/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6bin0 -> 55 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9efbin0 -> 81 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba43
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02bin0 -> 44 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1debin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d371
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47bin0 -> 125 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225bin0 -> 55 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4bin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2ebin0 -> 113 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo3_notes/refs/notes/commits1
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/HEAD1
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/config7
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/logs/HEAD4
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main4
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82eabin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1debin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf3443
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7cabin0 -> 160 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5bin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098bin0 -> 18 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4bin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684bin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78bin0 -> 126 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo5_pulls/HEAD1
-rw-r--r--modules/git/tests/repos/repo5_pulls/config6
-rw-r--r--modules/git/tests/repos/repo5_pulls/description1
-rw-r--r--modules/git/tests/repos/repo5_pulls/info/exclude6
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51dfbin0 -> 119 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbfbin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af1
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470abbin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb22
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd2
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17fbin0 -> 660 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f3
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640bin0 -> 650 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idxbin0 -> 1408 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.packbin0 -> 2363 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/packed-refs5
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/master-clone1
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-11
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/pull/4/head1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/config6
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/description1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/info/refs4
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graphbin0 -> 1544 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmapbin0 -> 414 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idxbin0 -> 1736 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.packbin0 -> 3140 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.revbin0 -> 140 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/packed-refs5
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo6_blame/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_blame/config4
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643cbin0 -> 98 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1bin0 -> 35 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376bin0 -> 167 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9bin0 -> 24 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7bin0 -> 175 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044bin0 -> 57 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93bin0 -> 134 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/config6
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/description1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/info/refs1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graphbin0 -> 1376 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmapbin0 -> 318 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idxbin0 -> 1456 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.packbin0 -> 904 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.revbin0 -> 112 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/packed-refs2
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/refs/refs/main1
-rw-r--r--modules/git/tests/repos/repo6_merge/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699bin0 -> 280 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5feccbin0 -> 152 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576bin0 -> 88 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e94112
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f414392
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482bbin0 -> 122 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597bin0 -> 26 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e25
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3abin0 -> 20 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831bin0 -> 26 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77bin0 -> 145 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2bin0 -> 33 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92bin0 -> 24 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/config6
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/description1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/info/refs4
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graphbin0 -> 1564 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/info/packs3
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmapbin0 -> 410 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idxbin0 -> 1696 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.packbin0 -> 1556 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.revbin0 -> 136 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idxbin0 -> 1176 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimesbin0 -> 84 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.packbin0 -> 447 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.revbin0 -> 84 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/packed-refs5
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/refs/heads/main1
-rw-r--r--modules/git/tree.go178
-rw-r--r--modules/git/tree_blob.go81
-rw-r--r--modules/git/tree_entry.go277
-rw-r--r--modules/git/tree_entry_mode.go35
-rw-r--r--modules/git/tree_test.go28
-rw-r--r--modules/git/url/url.go89
-rw-r--r--modules/git/url/url_test.go167
-rw-r--r--modules/git/utils.go138
-rw-r--r--modules/git/utils_test.go26
-rw-r--r--modules/gitgraph/graph.go116
-rw-r--r--modules/gitgraph/graph_models.go256
-rw-r--r--modules/gitgraph/graph_test.go714
-rw-r--r--modules/gitgraph/parser.go336
-rw-r--r--modules/gitrepo/branch.go49
-rw-r--r--modules/gitrepo/gitrepo.go103
-rw-r--r--modules/gitrepo/walk.go15
-rw-r--r--modules/graceful/context.go36
-rw-r--r--modules/graceful/manager.go260
-rw-r--r--modules/graceful/manager_common.go108
-rw-r--r--modules/graceful/manager_unix.go201
-rw-r--r--modules/graceful/manager_windows.go190
-rw-r--r--modules/graceful/net_unix.go321
-rw-r--r--modules/graceful/net_windows.go19
-rw-r--r--modules/graceful/releasereopen/releasereopen.go61
-rw-r--r--modules/graceful/releasereopen/releasereopen_test.go44
-rw-r--r--modules/graceful/restart_unix.go115
-rw-r--r--modules/graceful/server.go284
-rw-r--r--modules/graceful/server_hooks.go73
-rw-r--r--modules/graceful/server_http.go37
-rw-r--r--modules/hcaptcha/error.go47
-rw-r--r--modules/hcaptcha/hcaptcha.go140
-rw-r--r--modules/hcaptcha/hcaptcha_test.go106
-rw-r--r--modules/highlight/highlight.go224
-rw-r--r--modules/highlight/highlight_test.go190
-rw-r--r--modules/hostmatcher/hostmatcher.go161
-rw-r--r--modules/hostmatcher/hostmatcher_test.go161
-rw-r--r--modules/hostmatcher/http.go65
-rw-r--r--modules/html/html.go25
-rw-r--r--modules/httpcache/httpcache.go101
-rw-r--r--modules/httpcache/httpcache_test.go100
-rw-r--r--modules/httplib/request.go206
-rw-r--r--modules/httplib/serve.go237
-rw-r--r--modules/httplib/serve_test.go109
-rw-r--r--modules/httplib/url.go27
-rw-r--r--modules/httplib/url_test.go123
-rw-r--r--modules/indexer/code/bleve/bleve.go354
-rw-r--r--modules/indexer/code/elasticsearch/elasticsearch.go388
-rw-r--r--modules/indexer/code/elasticsearch/elasticsearch_test.go16
-rw-r--r--modules/indexer/code/git.go199
-rw-r--r--modules/indexer/code/indexer.go310
-rw-r--r--modules/indexer/code/indexer_test.go145
-rw-r--r--modules/indexer/code/internal/indexer.go54
-rw-r--r--modules/indexer/code/internal/model.go44
-rw-r--r--modules/indexer/code/internal/util.go32
-rw-r--r--modules/indexer/code/search.go228
-rw-r--r--modules/indexer/internal/base32.go21
-rw-r--r--modules/indexer/internal/bleve/batch.go58
-rw-r--r--modules/indexer/internal/bleve/indexer.go102
-rw-r--r--modules/indexer/internal/bleve/metadata.go55
-rw-r--r--modules/indexer/internal/bleve/metadata_test.go28
-rw-r--r--modules/indexer/internal/bleve/query.go56
-rw-r--r--modules/indexer/internal/bleve/util.go48
-rw-r--r--modules/indexer/internal/db/indexer.go34
-rw-r--r--modules/indexer/internal/elasticsearch/indexer.go93
-rw-r--r--modules/indexer/internal/elasticsearch/util.go68
-rw-r--r--modules/indexer/internal/indexer.go37
-rw-r--r--modules/indexer/internal/meilisearch/filter.go119
-rw-r--r--modules/indexer/internal/meilisearch/indexer.go88
-rw-r--r--modules/indexer/internal/meilisearch/util.go38
-rw-r--r--modules/indexer/internal/paginator.go34
-rw-r--r--modules/indexer/issues/bleve/bleve.go300
-rw-r--r--modules/indexer/issues/bleve/bleve_test.go18
-rw-r--r--modules/indexer/issues/db/db.go107
-rw-r--r--modules/indexer/issues/db/options.go112
-rw-r--r--modules/indexer/issues/dboptions.go105
-rw-r--r--modules/indexer/issues/elasticsearch/elasticsearch.go290
-rw-r--r--modules/indexer/issues/elasticsearch/elasticsearch_test.go48
-rw-r--r--modules/indexer/issues/indexer.go315
-rw-r--r--modules/indexer/issues/indexer_test.go410
-rw-r--r--modules/indexer/issues/internal/indexer.go42
-rw-r--r--modules/indexer/issues/internal/model.go150
-rw-r--r--modules/indexer/issues/internal/tests/tests.go771
-rw-r--r--modules/indexer/issues/meilisearch/meilisearch.go301
-rw-r--r--modules/indexer/issues/meilisearch/meilisearch_test.go97
-rw-r--r--modules/indexer/issues/util.go193
-rw-r--r--modules/indexer/stats/db.go84
-rw-r--r--modules/indexer/stats/indexer.go88
-rw-r--r--modules/indexer/stats/indexer_test.go52
-rw-r--r--modules/indexer/stats/queue.go49
-rw-r--r--modules/issue/template/template.go489
-rw-r--r--modules/issue/template/template_test.go963
-rw-r--r--modules/issue/template/unmarshal.go147
-rw-r--r--modules/json/json.go172
-rw-r--r--modules/keying/keying.go125
-rw-r--r--modules/keying/keying_test.go111
-rw-r--r--modules/label/label.go46
-rw-r--r--modules/label/parser.go118
-rw-r--r--modules/label/parser_test.go72
-rw-r--r--modules/lfs/LICENSE20
-rw-r--r--modules/lfs/client.go32
-rw-r--r--modules/lfs/client_test.go21
-rw-r--r--modules/lfs/content_store.go163
-rw-r--r--modules/lfs/endpoint.go107
-rw-r--r--modules/lfs/endpoint_test.go74
-rw-r--r--modules/lfs/filesystem_client.go88
-rw-r--r--modules/lfs/http_client.go259
-rw-r--r--modules/lfs/http_client_test.go377
-rw-r--r--modules/lfs/pointer.go129
-rw-r--r--modules/lfs/pointer_scanner.go109
-rw-r--r--modules/lfs/pointer_test.go103
-rw-r--r--modules/lfs/shared.go115
-rw-r--r--modules/lfs/transferadapter.go89
-rw-r--r--modules/lfs/transferadapter_test.go172
-rw-r--r--modules/log/color.go115
-rw-r--r--modules/log/color_console.go17
-rw-r--r--modules/log/color_console_other.go69
-rw-r--r--modules/log/color_console_windows.go42
-rw-r--r--modules/log/color_router.go87
-rw-r--r--modules/log/event_format.go253
-rw-r--r--modules/log/event_format_test.go114
-rw-r--r--modules/log/event_writer.go54
-rw-r--r--modules/log/event_writer_base.go169
-rw-r--r--modules/log/event_writer_conn.go111
-rw-r--r--modules/log/event_writer_conn_test.go76
-rw-r--r--modules/log/event_writer_console.go40
-rw-r--r--modules/log/event_writer_file.go53
-rw-r--r--modules/log/flags.go138
-rw-r--r--modules/log/flags_test.go31
-rw-r--r--modules/log/groutinelabel.go19
-rw-r--r--modules/log/groutinelabel_test.go33
-rw-r--r--modules/log/init.go44
-rw-r--r--modules/log/level.go136
-rw-r--r--modules/log/level_test.go56
-rw-r--r--modules/log/logger.go50
-rw-r--r--modules/log/logger_global.go83
-rw-r--r--modules/log/logger_impl.go240
-rw-r--r--modules/log/logger_test.go146
-rw-r--r--modules/log/manager.go142
-rw-r--r--modules/log/manager_test.go43
-rw-r--r--modules/log/misc.go78
-rw-r--r--modules/log/stack.go80
-rw-r--r--modules/markup/asciicast/asciicast.go64
-rw-r--r--modules/markup/camo.go46
-rw-r--r--modules/markup/camo_test.go44
-rw-r--r--modules/markup/common/footnote.go498
-rw-r--r--modules/markup/common/footnote_test.go62
-rw-r--r--modules/markup/common/html.go16
-rw-r--r--modules/markup/common/linkify.go153
-rw-r--r--modules/markup/console/console.go89
-rw-r--r--modules/markup/console/console_test.go33
-rw-r--r--modules/markup/csv/csv.go157
-rw-r--r--modules/markup/csv/csv_test.go33
-rw-r--r--modules/markup/external/external.go146
-rw-r--r--modules/markup/file_preview.go364
-rw-r--r--modules/markup/html.go1325
-rw-r--r--modules/markup/html_internal_test.go486
-rw-r--r--modules/markup/html_test.go1029
-rw-r--r--modules/markup/markdown/ast.go176
-rw-r--r--modules/markup/markdown/callout/ast.go37
-rw-r--r--modules/markup/markdown/callout/github.go141
-rw-r--r--modules/markup/markdown/callout/github_legacy.go70
-rw-r--r--modules/markup/markdown/color_util.go19
-rw-r--r--modules/markup/markdown/color_util_test.go50
-rw-r--r--modules/markup/markdown/convertyaml.go83
-rw-r--r--modules/markup/markdown/goldmark.go213
-rw-r--r--modules/markup/markdown/markdown.go303
-rw-r--r--modules/markup/markdown/markdown_test.go1359
-rw-r--r--modules/markup/markdown/math/block_node.go41
-rw-r--r--modules/markup/markdown/math/block_parser.go125
-rw-r--r--modules/markup/markdown/math/block_renderer.go42
-rw-r--r--modules/markup/markdown/math/inline_block_node.go31
-rw-r--r--modules/markup/markdown/math/inline_node.go48
-rw-r--r--modules/markup/markdown/math/inline_parser.go153
-rw-r--r--modules/markup/markdown/math/inline_renderer.go51
-rw-r--r--modules/markup/markdown/math/math.go108
-rw-r--r--modules/markup/markdown/meta.go103
-rw-r--r--modules/markup/markdown/meta_test.go110
-rw-r--r--modules/markup/markdown/prefixed_id.go59
-rw-r--r--modules/markup/markdown/renderconfig.go126
-rw-r--r--modules/markup/markdown/renderconfig_test.go162
-rw-r--r--modules/markup/markdown/toc.go54
-rw-r--r--modules/markup/markdown/transform_codespan.go56
-rw-r--r--modules/markup/markdown/transform_heading.go32
-rw-r--r--modules/markup/markdown/transform_image.go65
-rw-r--r--modules/markup/markdown/transform_link.go46
-rw-r--r--modules/markup/markdown/transform_list.go85
-rw-r--r--modules/markup/mdstripper/mdstripper.go199
-rw-r--r--modules/markup/mdstripper/mdstripper_test.go85
-rw-r--r--modules/markup/orgmode/orgmode.go196
-rw-r--r--modules/markup/orgmode/orgmode_test.go160
-rw-r--r--modules/markup/renderer.go393
-rw-r--r--modules/markup/renderer_test.go4
-rw-r--r--modules/markup/sanitizer.go235
-rw-r--r--modules/markup/sanitizer_test.go110
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/HEAD1
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/config6
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/description1
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/info/exclude6
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20bin0 -> 120 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/3f/ed9bce8610a52048747f627b3863374642c85cbin0 -> 91 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904bin0 -> 15 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/4c/1aaf56bcb9f39dcf65f3f250726850aed13cd6bin0 -> 187 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972bin0 -> 44 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969bin0 -> 23 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/8c/7e5a667f1b771847fe88c01c3de34413a1b220bin0 -> 16 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4cbin0 -> 46 bytes
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d1
-rw-r--r--modules/markup/tests/repo/repo1_filepreview/refs/heads/master1
-rw-r--r--modules/mcaptcha/mcaptcha.go26
-rwxr-xr-xmodules/metrics/collector.go388
-rw-r--r--modules/migration/comment.go34
-rw-r--r--modules/migration/downloader.go37
-rw-r--r--modules/migration/error.go25
-rw-r--r--modules/migration/file_format.go110
-rw-r--r--modules/migration/file_format_test.go39
-rw-r--r--modules/migration/file_format_testdata/issue_a.json14
-rw-r--r--modules/migration/file_format_testdata/issue_a.yml10
-rw-r--r--modules/migration/file_format_testdata/issue_b.json5
-rw-r--r--modules/migration/file_format_testdata/milestones.json20
-rw-r--r--modules/migration/issue.go48
-rw-r--r--modules/migration/label.go13
-rw-r--r--modules/migration/messenger.go10
-rw-r--r--modules/migration/milestone.go18
-rw-r--r--modules/migration/null_downloader.go88
-rw-r--r--modules/migration/options.go41
-rw-r--r--modules/migration/pullrequest.go74
-rw-r--r--modules/migration/reaction.go17
-rw-r--r--modules/migration/release.go46
-rw-r--r--modules/migration/repo.go17
-rw-r--r--modules/migration/retry_downloader.go194
-rw-r--r--modules/migration/review.go67
-rw-r--r--modules/migration/schemas/issue.json114
-rw-r--r--modules/migration/schemas/label.json28
-rw-r--r--modules/migration/schemas/milestone.json67
-rw-r--r--modules/migration/schemas/reaction.json29
-rw-r--r--modules/migration/schemas_bindata.go8
-rw-r--r--modules/migration/schemas_dynamic.go47
-rw-r--r--modules/migration/schemas_static.go23
-rw-r--r--modules/migration/uploader.go23
-rw-r--r--modules/nosql/leveldb.go24
-rw-r--r--modules/nosql/manager.go116
-rw-r--r--modules/nosql/manager_leveldb.go214
-rw-r--r--modules/nosql/manager_redis.go258
-rw-r--r--modules/nosql/manager_redis_test.go81
-rw-r--r--modules/nosql/redis.go100
-rw-r--r--modules/nosql/redis_test.go34
-rw-r--r--modules/optional/option.go45
-rw-r--r--modules/optional/option_test.go59
-rw-r--r--modules/optional/serialization.go46
-rw-r--r--modules/optional/serialization_test.go191
-rw-r--r--modules/options/base.go42
-rw-r--r--modules/options/dynamic.go15
-rw-r--r--modules/options/options_bindata.go8
-rw-r--r--modules/options/static.go14
-rw-r--r--modules/packages/alpine/metadata.go242
-rw-r--r--modules/packages/alpine/metadata_test.go144
-rw-r--r--modules/packages/arch/metadata.go341
-rw-r--r--modules/packages/arch/metadata_test.go447
-rw-r--r--modules/packages/cargo/parser.go169
-rw-r--r--modules/packages/cargo/parser_test.go87
-rw-r--r--modules/packages/chef/metadata.go134
-rw-r--r--modules/packages/chef/metadata_test.go93
-rw-r--r--modules/packages/composer/metadata.go187
-rw-r--r--modules/packages/composer/metadata_test.go154
-rw-r--r--modules/packages/conan/conanfile_parser.go67
-rw-r--r--modules/packages/conan/conanfile_parser_test.go51
-rw-r--r--modules/packages/conan/conaninfo_parser.go123
-rw-r--r--modules/packages/conan/conaninfo_parser_test.go85
-rw-r--r--modules/packages/conan/metadata.go23
-rw-r--r--modules/packages/conan/reference.go155
-rw-r--r--modules/packages/conan/reference_test.go148
-rw-r--r--modules/packages/conda/metadata.go242
-rw-r--r--modules/packages/conda/metadata_test.go152
-rw-r--r--modules/packages/container/helm/helm.go55
-rw-r--r--modules/packages/container/metadata.go166
-rw-r--r--modules/packages/container/metadata_test.go62
-rw-r--r--modules/packages/content_store.go75
-rw-r--r--modules/packages/cran/metadata.go242
-rw-r--r--modules/packages/cran/metadata_test.go153
-rw-r--r--modules/packages/debian/metadata.go221
-rw-r--r--modules/packages/debian/metadata_test.go187
-rw-r--r--modules/packages/goproxy/metadata.go94
-rw-r--r--modules/packages/goproxy/metadata_test.go76
-rw-r--r--modules/packages/hashed_buffer.go81
-rw-r--r--modules/packages/hashed_buffer_test.go47
-rw-r--r--modules/packages/helm/metadata.go130
-rw-r--r--modules/packages/maven/metadata.go93
-rw-r--r--modules/packages/maven/metadata_test.go90
-rw-r--r--modules/packages/multi_hasher.go122
-rw-r--r--modules/packages/multi_hasher_test.go54
-rw-r--r--modules/packages/npm/creator.go289
-rw-r--r--modules/packages/npm/creator_test.go302
-rw-r--r--modules/packages/npm/metadata.go26
-rw-r--r--modules/packages/nuget/metadata.go239
-rw-r--r--modules/packages/nuget/metadata_test.go188
-rw-r--r--modules/packages/nuget/symbol_extractor.go186
-rw-r--r--modules/packages/nuget/symbol_extractor_test.go82
-rw-r--r--modules/packages/pub/metadata.go153
-rw-r--r--modules/packages/pub/metadata_test.go136
-rw-r--r--modules/packages/pypi/metadata.go15
-rw-r--r--modules/packages/rpm/metadata.go298
-rw-r--r--modules/packages/rpm/metadata_test.go164
-rw-r--r--modules/packages/rubygems/marshal.go311
-rw-r--r--modules/packages/rubygems/marshal_test.go99
-rw-r--r--modules/packages/rubygems/metadata.go220
-rw-r--r--modules/packages/rubygems/metadata_test.go89
-rw-r--r--modules/packages/swift/metadata.go214
-rw-r--r--modules/packages/swift/metadata_test.go145
-rw-r--r--modules/packages/vagrant/metadata.go96
-rw-r--r--modules/packages/vagrant/metadata_test.go111
-rw-r--r--modules/paginator/paginator.go204
-rw-r--r--modules/paginator/paginator_test.go312
-rw-r--r--modules/pprof/pprof.go45
-rw-r--r--modules/private/actions.go25
-rw-r--r--modules/private/forgejo_actions.go32
-rw-r--r--modules/private/hook.go129
-rw-r--r--modules/private/internal.go96
-rw-r--r--modules/private/key.go30
-rw-r--r--modules/private/mail.go33
-rw-r--r--modules/private/manager.go120
-rw-r--r--modules/private/request.go128
-rw-r--r--modules/private/restore_repo.go36
-rw-r--r--modules/private/serv.go63
-rw-r--r--modules/process/context.go68
-rw-r--r--modules/process/error.go25
-rw-r--r--modules/process/manager.go243
-rw-r--r--modules/process/manager_exec.go79
-rw-r--r--modules/process/manager_stacktraces.go353
-rw-r--r--modules/process/manager_test.go111
-rw-r--r--modules/process/manager_unix.go17
-rw-r--r--modules/process/manager_windows.go15
-rw-r--r--modules/process/process.go38
-rw-r--r--modules/proxy/proxy.go98
-rw-r--r--modules/proxyprotocol/conn.go505
-rw-r--r--modules/proxyprotocol/errors.go44
-rw-r--r--modules/proxyprotocol/listener.go46
-rw-r--r--modules/proxyprotocol/util.go14
-rw-r--r--modules/public/mime_types.go40
-rw-r--r--modules/public/public.go118
-rw-r--r--modules/public/public_bindata.go8
-rw-r--r--modules/public/public_test.go34
-rw-r--r--modules/public/serve_dynamic.go15
-rw-r--r--modules/public/serve_static.go24
-rw-r--r--modules/queue/backoff.go63
-rw-r--r--modules/queue/base.go42
-rw-r--r--modules/queue/base_channel.go131
-rw-r--r--modules/queue/base_channel_test.go11
-rw-r--r--modules/queue/base_dummy.go38
-rw-r--r--modules/queue/base_levelqueue.go83
-rw-r--r--modules/queue/base_levelqueue_common.go93
-rw-r--r--modules/queue/base_levelqueue_test.go78
-rw-r--r--modules/queue/base_levelqueue_unique.go88
-rw-r--r--modules/queue/base_redis.go162
-rw-r--r--modules/queue/base_redis_test.go138
-rw-r--r--modules/queue/base_redis_with_server_test.go133
-rw-r--r--modules/queue/base_test.go141
-rw-r--r--modules/queue/config.go36
-rw-r--r--modules/queue/lqinternal/lqinternal.go48
-rw-r--r--modules/queue/manager.go113
-rw-r--r--modules/queue/manager_test.go125
-rw-r--r--modules/queue/mock/inmemorymockredis.go133
-rw-r--r--modules/queue/mock/redisuniversalclient.go343
-rw-r--r--modules/queue/queue.go68
-rw-r--r--modules/queue/testhelper.go40
-rw-r--r--modules/queue/workergroup.go350
-rw-r--r--modules/queue/workerqueue.go260
-rw-r--r--modules/queue/workerqueue_test.go291
-rw-r--r--modules/recaptcha/recaptcha.go90
-rw-r--r--modules/references/references.go594
-rw-r--r--modules/references/references_test.go563
-rw-r--r--modules/regexplru/regexplru.go44
-rw-r--r--modules/regexplru/regexplru_test.go27
-rw-r--r--modules/repository/branch.go145
-rw-r--r--modules/repository/branch_test.go32
-rw-r--r--modules/repository/collaborator.go44
-rw-r--r--modules/repository/collaborator_test.go308
-rw-r--r--modules/repository/commits.go173
-rw-r--r--modules/repository/commits_test.go210
-rw-r--r--modules/repository/create.go297
-rw-r--r--modules/repository/create_test.go46
-rw-r--r--modules/repository/delete.go33
-rw-r--r--modules/repository/env.go87
-rw-r--r--modules/repository/fork.go32
-rw-r--r--modules/repository/hooks.go233
-rw-r--r--modules/repository/init.go182
-rw-r--r--modules/repository/init_test.go30
-rw-r--r--modules/repository/license.go112
-rw-r--r--modules/repository/license_test.go181
-rw-r--r--modules/repository/main_test.go16
-rw-r--r--modules/repository/push.go70
-rw-r--r--modules/repository/repo.go388
-rw-r--r--modules/repository/repo_test.go76
-rw-r--r--modules/repository/temp.go45
-rw-r--r--modules/secret/secret.go78
-rw-r--r--modules/secret/secret_test.go32
-rw-r--r--modules/session/db.go171
-rw-r--r--modules/session/redis.go225
-rw-r--r--modules/session/store.go29
-rw-r--r--modules/session/virtual.go198
-rw-r--r--modules/setting/actions.go106
-rw-r--r--modules/setting/actions_test.go157
-rw-r--r--modules/setting/admin.go32
-rw-r--r--modules/setting/admin_test.go33
-rw-r--r--modules/setting/api.go40
-rw-r--r--modules/setting/asset_dynamic.go8
-rw-r--r--modules/setting/asset_static.go8
-rw-r--r--modules/setting/attachment.go35
-rw-r--r--modules/setting/attachment_test.go134
-rw-r--r--modules/setting/badges.go24
-rw-r--r--modules/setting/cache.go85
-rw-r--r--modules/setting/camo.go32
-rw-r--r--modules/setting/config.go98
-rw-r--r--modules/setting/config/getter.go49
-rw-r--r--modules/setting/config/value.go94
-rw-r--r--modules/setting/config_env.go170
-rw-r--r--modules/setting/config_env_test.go151
-rw-r--r--modules/setting/config_provider.go360
-rw-r--r--modules/setting/config_provider_test.go157
-rw-r--r--modules/setting/cors.go34
-rw-r--r--modules/setting/cron.go32
-rw-r--r--modules/setting/cron_test.go44
-rw-r--r--modules/setting/database.go204
-rw-r--r--modules/setting/database_sqlite.go15
-rw-r--r--modules/setting/database_test.go109
-rw-r--r--modules/setting/f3.go26
-rw-r--r--modules/setting/federation.go51
-rw-r--r--modules/setting/forgejo_storage_test.go264
-rw-r--r--modules/setting/git.go123
-rw-r--r--modules/setting/git_test.go66
-rw-r--r--modules/setting/highlight.go17
-rw-r--r--modules/setting/i18n.go68
-rw-r--r--modules/setting/incoming_email.go89
-rw-r--r--modules/setting/incoming_email_test.go74
-rw-r--r--modules/setting/indexer.go119
-rw-r--r--modules/setting/indexer_test.go71
-rw-r--r--modules/setting/lfs.go82
-rw-r--r--modules/setting/lfs_test.go102
-rw-r--r--modules/setting/log.go270
-rw-r--r--modules/setting/log_test.go386
-rw-r--r--modules/setting/mailer.go309
-rw-r--r--modules/setting/mailer_test.go54
-rw-r--r--modules/setting/markup.go192
-rw-r--r--modules/setting/metrics.go21
-rw-r--r--modules/setting/migrations.go28
-rw-r--r--modules/setting/mime_type_map.go28
-rw-r--r--modules/setting/mirror.go58
-rw-r--r--modules/setting/oauth2.go174
-rw-r--r--modules/setting/oauth2_test.go61
-rw-r--r--modules/setting/other.go29
-rw-r--r--modules/setting/packages.go124
-rw-r--r--modules/setting/packages_test.go199
-rw-r--r--modules/setting/path.go214
-rw-r--r--modules/setting/path_test.go243
-rw-r--r--modules/setting/picture.go109
-rw-r--r--modules/setting/project.go19
-rw-r--r--modules/setting/proxy.go37
-rw-r--r--modules/setting/queue.go120
-rw-r--r--modules/setting/quota.go26
-rw-r--r--modules/setting/repository.go376
-rw-r--r--modules/setting/repository_archive.go25
-rw-r--r--modules/setting/repository_archive_test.go112
-rw-r--r--modules/setting/security.go173
-rw-r--r--modules/setting/server.go368
-rw-r--r--modules/setting/server_test.go36
-rw-r--r--modules/setting/service.go262
-rw-r--r--modules/setting/service_test.go133
-rw-r--r--modules/setting/session.go78
-rw-r--r--modules/setting/setting.go238
-rw-r--r--modules/setting/setting_test.go32
-rw-r--r--modules/setting/ssh.go197
-rw-r--r--modules/setting/storage.go275
-rw-r--r--modules/setting/storage_test.go468
-rw-r--r--modules/setting/task.go26
-rw-r--r--modules/setting/time.go28
-rw-r--r--modules/setting/ui.go169
-rw-r--r--modules/setting/webhook.go48
-rw-r--r--modules/sitemap/sitemap.go82
-rw-r--r--modules/sitemap/sitemap_test.go167
-rw-r--r--modules/ssh/init.go55
-rw-r--r--modules/ssh/ssh.go387
-rw-r--r--modules/ssh/ssh_graceful.go34
-rw-r--r--modules/storage/helper.go39
-rw-r--r--modules/storage/helper_test.go51
-rw-r--r--modules/storage/local.go154
-rw-r--r--modules/storage/local_test.go61
-rw-r--r--modules/storage/minio.go310
-rw-r--r--modules/storage/minio_test.go216
-rw-r--r--modules/storage/storage.go226
-rw-r--r--modules/storage/storage_test.go52
-rw-r--r--modules/storage/testdata/aws_credentials3
-rw-r--r--modules/storage/testdata/minio.json12
-rw-r--r--modules/structs/activity.go25
-rw-r--r--modules/structs/activitypub.go9
-rw-r--r--modules/structs/admin_user.go53
-rw-r--r--modules/structs/attachment.go31
-rw-r--r--modules/structs/commit_status.go73
-rw-r--r--modules/structs/commit_status_test.go174
-rw-r--r--modules/structs/cron.go15
-rw-r--r--modules/structs/doc.go4
-rw-r--r--modules/structs/fork.go12
-rw-r--r--modules/structs/git_blob.go13
-rw-r--r--modules/structs/git_hook.go19
-rw-r--r--modules/structs/hook.go518
-rw-r--r--modules/structs/issue.go269
-rw-r--r--modules/structs/issue_comment.go86
-rw-r--r--modules/structs/issue_label.go75
-rw-r--r--modules/structs/issue_milestone.go44
-rw-r--r--modules/structs/issue_reaction.go21
-rw-r--r--modules/structs/issue_stopwatch.go23
-rw-r--r--modules/structs/issue_test.go106
-rw-r--r--modules/structs/issue_tracked_time.go37
-rw-r--r--modules/structs/lfs_lock.go64
-rw-r--r--modules/structs/mirror.go32
-rw-r--r--modules/structs/miscellaneous.go101
-rw-r--r--modules/structs/moderation.go13
-rw-r--r--modules/structs/nodeinfo.go43
-rw-r--r--modules/structs/notifications.go49
-rw-r--r--modules/structs/org.go59
-rw-r--r--modules/structs/org_member.go9
-rw-r--r--modules/structs/org_team.go54
-rw-r--r--modules/structs/package.go33
-rw-r--r--modules/structs/pull.go119
-rw-r--r--modules/structs/pull_review.go111
-rw-r--r--modules/structs/quota.go163
-rw-r--r--modules/structs/release.go55
-rw-r--r--modules/structs/repo.go421
-rw-r--r--modules/structs/repo_actions.go34
-rw-r--r--modules/structs/repo_branch.go112
-rw-r--r--modules/structs/repo_collaborator.go17
-rw-r--r--modules/structs/repo_commit.go73
-rw-r--r--modules/structs/repo_compare.go10
-rw-r--r--modules/structs/repo_file.go172
-rw-r--r--modules/structs/repo_flags.go9
-rw-r--r--modules/structs/repo_key.go40
-rw-r--r--modules/structs/repo_note.go10
-rw-r--r--modules/structs/repo_refs.go18
-rw-r--r--modules/structs/repo_tag.go76
-rw-r--r--modules/structs/repo_topic.go28
-rw-r--r--modules/structs/repo_tree.go24
-rw-r--r--modules/structs/repo_watch.go18
-rw-r--r--modules/structs/repo_wiki.go46
-rw-r--r--modules/structs/secret.go24
-rw-r--r--modules/structs/settings.go38
-rw-r--r--modules/structs/status.go42
-rw-r--r--modules/structs/task.go31
-rw-r--r--modules/structs/user.go120
-rw-r--r--modules/structs/user_app.go53
-rw-r--r--modules/structs/user_email.go27
-rw-r--r--modules/structs/user_gpgkey.go53
-rw-r--r--modules/structs/user_key.go22
-rw-r--r--modules/structs/variable.go37
-rw-r--r--modules/structs/visible_type.go58
-rw-r--r--modules/structs/workflow.go15
-rw-r--r--modules/svg/processor.go59
-rw-r--r--modules/svg/processor_test.go29
-rw-r--r--modules/svg/svg.go59
-rw-r--r--modules/sync/exclusive_pool.go69
-rw-r--r--modules/sync/status_pool.go57
-rw-r--r--modules/sync/status_pool_test.go31
-rw-r--r--modules/system/appstate.go26
-rw-r--r--modules/system/appstate_test.go66
-rw-r--r--modules/system/db.go36
-rw-r--r--modules/system/item_runtime.go15
-rw-r--r--modules/templates/base.go40
-rw-r--r--modules/templates/dynamic.go15
-rw-r--r--modules/templates/eval/eval.go344
-rw-r--r--modules/templates/eval/eval_test.go94
-rw-r--r--modules/templates/helper.go269
-rw-r--r--modules/templates/helper_test.go67
-rw-r--r--modules/templates/htmlrenderer.go287
-rw-r--r--modules/templates/htmlrenderer_test.go107
-rw-r--r--modules/templates/mailer.go110
-rw-r--r--modules/templates/main_test.go24
-rw-r--r--modules/templates/scopedtmpl/scopedtmpl.go239
-rw-r--r--modules/templates/scopedtmpl/scopedtmpl_test.go99
-rw-r--r--modules/templates/static.go22
-rw-r--r--modules/templates/templates_bindata.go8
-rw-r--r--modules/templates/util_avatar.go81
-rw-r--r--modules/templates/util_dict.go121
-rw-r--r--modules/templates/util_json.go35
-rw-r--r--modules/templates/util_misc.go193
-rw-r--r--modules/templates/util_render.go264
-rw-r--r--modules/templates/util_render_test.go223
-rw-r--r--modules/templates/util_slice.go35
-rw-r--r--modules/templates/util_string.go68
-rw-r--r--modules/templates/util_string_test.go20
-rw-r--r--modules/templates/util_test.go79
-rw-r--r--modules/templates/vars/vars.go92
-rw-r--r--modules/templates/vars/vars_test.go72
-rw-r--r--modules/test/logchecker.go107
-rw-r--r--modules/test/logchecker_test.go58
-rw-r--r--modules/test/utils.go48
-rw-r--r--modules/test/utils_test.go18
-rw-r--r--modules/testlogger/testlogger.go578
-rw-r--r--modules/timeutil/datetime.go68
-rw-r--r--modules/timeutil/datetime_test.go47
-rw-r--r--modules/timeutil/executable.go50
-rw-r--r--modules/timeutil/since.go145
-rw-r--r--modules/timeutil/since_test.go87
-rw-r--r--modules/timeutil/timestamp.go100
-rw-r--r--modules/timeutil/timestampnano.go28
-rw-r--r--modules/translation/i18n/errors.go13
-rw-r--r--modules/translation/i18n/format.go41
-rw-r--r--modules/translation/i18n/i18n.go50
-rw-r--r--modules/translation/i18n/i18n_test.go204
-rw-r--r--modules/translation/i18n/localestore.go166
-rw-r--r--modules/translation/mock.go40
-rw-r--r--modules/translation/translation.go303
-rw-r--r--modules/translation/translation_test.go50
-rw-r--r--modules/turnstile/turnstile.go92
-rw-r--r--modules/typesniffer/typesniffer.go143
-rw-r--r--modules/typesniffer/typesniffer_test.go137
-rw-r--r--modules/updatechecker/update_checker.go143
-rw-r--r--modules/updatechecker/update_checker_test.go17
-rw-r--r--modules/uri/uri.go42
-rw-r--r--modules/uri/uri_test.go19
-rw-r--r--modules/user/user.go35
-rw-r--r--modules/user/user_test.go43
-rw-r--r--modules/util/color.go57
-rw-r--r--modules/util/color_test.go63
-rw-r--r--modules/util/error.go65
-rw-r--r--modules/util/file_unix.go27
-rw-r--r--modules/util/file_unix_test.go36
-rw-r--r--modules/util/file_windows.go15
-rw-r--r--modules/util/filebuffer/file_backed_buffer.go156
-rw-r--r--modules/util/filebuffer/file_backed_buffer_test.go36
-rw-r--r--modules/util/io.go78
-rw-r--r--modules/util/io_test.go67
-rw-r--r--modules/util/keypair.go57
-rw-r--r--modules/util/keypair_test.go62
-rw-r--r--modules/util/legacy.go38
-rw-r--r--modules/util/legacy_test.go38
-rw-r--r--modules/util/pack.go33
-rw-r--r--modules/util/pack_test.go28
-rw-r--r--modules/util/paginate.go33
-rw-r--r--modules/util/paginate_test.go46
-rw-r--r--modules/util/path.go322
-rw-r--r--modules/util/path_test.go213
-rw-r--r--modules/util/remove.go104
-rw-r--r--modules/util/rotatingfilewriter/writer.go246
-rw-r--r--modules/util/rotatingfilewriter/writer_test.go49
-rw-r--r--modules/util/sanitize.go72
-rw-r--r--modules/util/sanitize_test.go74
-rw-r--r--modules/util/sec_to_time.go81
-rw-r--r--modules/util/sec_to_time_test.go30
-rw-r--r--modules/util/shellquote.go101
-rw-r--r--modules/util/shellquote_test.go91
-rw-r--r--modules/util/slice.go73
-rw-r--r--modules/util/slice_test.go55
-rw-r--r--modules/util/string.go97
-rw-r--r--modules/util/string_test.go47
-rw-r--r--modules/util/timer.go36
-rw-r--r--modules/util/timer_test.go30
-rw-r--r--modules/util/truncate.go54
-rw-r--r--modules/util/truncate_test.go46
-rw-r--r--modules/util/url.go50
-rw-r--r--modules/util/util.go264
-rw-r--r--modules/util/util_test.go277
-rw-r--r--modules/validation/binding.go209
-rw-r--r--modules/validation/binding_test.go62
-rw-r--r--modules/validation/glob_pattern_test.go61
-rw-r--r--modules/validation/helpers.go136
-rw-r--r--modules/validation/helpers_test.go216
-rw-r--r--modules/validation/refname_test.go265
-rw-r--r--modules/validation/regex_pattern_test.go59
-rw-r--r--modules/validation/validatable.go84
-rw-r--r--modules/validation/validatable_test.go69
-rw-r--r--modules/validation/validurl_test.go110
-rw-r--r--modules/web/handler.go193
-rw-r--r--modules/web/middleware/binding.go162
-rw-r--r--modules/web/middleware/cookie.go85
-rw-r--r--modules/web/middleware/data.go63
-rw-r--r--modules/web/middleware/flash.go65
-rw-r--r--modules/web/middleware/locale.go59
-rw-r--r--modules/web/middleware/request.go14
-rw-r--r--modules/web/route.go211
-rw-r--r--modules/web/route_test.go179
-rw-r--r--modules/web/routemock.go61
-rw-r--r--modules/web/routemock_test.go71
-rw-r--r--modules/web/routing/context.go49
-rw-r--r--modules/web/routing/funcinfo.go172
-rw-r--r--modules/web/routing/funcinfo_test.go80
-rw-r--r--modules/web/routing/logger.go109
-rw-r--r--modules/web/routing/logger_manager.go124
-rw-r--r--modules/web/routing/requestrecord.go28
-rw-r--r--modules/web/types/response.go10
-rw-r--r--modules/webhook/structs.go39
-rw-r--r--modules/webhook/type.go100
-rw-r--r--modules/zstd/option.go46
-rw-r--r--modules/zstd/zstd.go163
-rw-r--r--modules/zstd/zstd_test.go304
1114 files changed, 109812 insertions, 0 deletions
diff --git a/modules/actions/github.go b/modules/actions/github.go
new file mode 100644
index 0000000..c27d4ed
--- /dev/null
+++ b/modules/actions/github.go
@@ -0,0 +1,133 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+)
+
+const (
+ GithubEventPullRequest = "pull_request"
+ GithubEventPullRequestTarget = "pull_request_target"
+ GithubEventPullRequestReviewComment = "pull_request_review_comment"
+ GithubEventPullRequestReview = "pull_request_review"
+ GithubEventRegistryPackage = "registry_package"
+ GithubEventCreate = "create"
+ GithubEventDelete = "delete"
+ GithubEventFork = "fork"
+ GithubEventPush = "push"
+ GithubEventIssues = "issues"
+ GithubEventIssueComment = "issue_comment"
+ GithubEventRelease = "release"
+ GithubEventPullRequestComment = "pull_request_comment"
+ GithubEventGollum = "gollum"
+ GithubEventSchedule = "schedule"
+ GithubEventWorkflowDispatch = "workflow_dispatch"
+)
+
+// IsDefaultBranchWorkflow returns true if the event only triggers workflows on the default branch
+func IsDefaultBranchWorkflow(triggedEvent webhook_module.HookEventType) bool {
+ switch triggedEvent {
+ case webhook_module.HookEventDelete:
+ // GitHub "delete" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#delete
+ return true
+ case webhook_module.HookEventFork:
+ // GitHub "fork" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#fork
+ return true
+ case webhook_module.HookEventIssueComment:
+ // GitHub "issue_comment" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment
+ return true
+ case webhook_module.HookEventPullRequestComment:
+ // GitHub "pull_request_comment" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
+ return true
+ case webhook_module.HookEventWiki:
+ // GitHub "gollum" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#gollum
+ return true
+ case webhook_module.HookEventSchedule:
+ // GitHub "schedule" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
+ return true
+ case webhook_module.HookEventWorkflowDispatch:
+ // GitHub "workflow_dispatch" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
+ return true
+ case webhook_module.HookEventIssues,
+ webhook_module.HookEventIssueAssign,
+ webhook_module.HookEventIssueLabel,
+ webhook_module.HookEventIssueMilestone:
+ // Github "issues" event
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues
+ return true
+ }
+
+ return false
+}
+
+// canGithubEventMatch check if the input Github event can match any Gitea event.
+func canGithubEventMatch(eventName string, triggedEvent webhook_module.HookEventType) bool {
+ switch eventName {
+ case GithubEventRegistryPackage:
+ return triggedEvent == webhook_module.HookEventPackage
+
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#gollum
+ case GithubEventGollum:
+ return triggedEvent == webhook_module.HookEventWiki
+
+ case GithubEventWorkflowDispatch:
+ return triggedEvent == webhook_module.HookEventWorkflowDispatch
+
+ case GithubEventIssues:
+ switch triggedEvent {
+ case webhook_module.HookEventIssues,
+ webhook_module.HookEventIssueAssign,
+ webhook_module.HookEventIssueLabel,
+ webhook_module.HookEventIssueMilestone:
+ return true
+
+ default:
+ return false
+ }
+
+ case GithubEventPullRequest, GithubEventPullRequestTarget:
+ switch triggedEvent {
+ case webhook_module.HookEventPullRequest,
+ webhook_module.HookEventPullRequestSync,
+ webhook_module.HookEventPullRequestAssign,
+ webhook_module.HookEventPullRequestLabel,
+ webhook_module.HookEventPullRequestReviewRequest,
+ webhook_module.HookEventPullRequestMilestone:
+ return true
+
+ default:
+ return false
+ }
+
+ case GithubEventPullRequestReview:
+ switch triggedEvent {
+ case webhook_module.HookEventPullRequestReviewApproved,
+ webhook_module.HookEventPullRequestReviewComment,
+ webhook_module.HookEventPullRequestReviewRejected:
+ return true
+
+ default:
+ return false
+ }
+
+ case GithubEventIssueComment:
+ // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
+ return triggedEvent == webhook_module.HookEventIssueComment ||
+ triggedEvent == webhook_module.HookEventPullRequestComment
+
+ case GithubEventSchedule:
+ return triggedEvent == webhook_module.HookEventSchedule
+
+ default:
+ return eventName == string(triggedEvent)
+ }
+}
diff --git a/modules/actions/github_test.go b/modules/actions/github_test.go
new file mode 100644
index 0000000..6652ff6
--- /dev/null
+++ b/modules/actions/github_test.go
@@ -0,0 +1,119 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "testing"
+
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCanGithubEventMatch(t *testing.T) {
+ testCases := []struct {
+ desc string
+ eventName string
+ triggeredEvent webhook_module.HookEventType
+ expected bool
+ }{
+ // registry_package event
+ {
+ "registry_package matches",
+ GithubEventRegistryPackage,
+ webhook_module.HookEventPackage,
+ true,
+ },
+ {
+ "registry_package cannot match",
+ GithubEventRegistryPackage,
+ webhook_module.HookEventPush,
+ false,
+ },
+ // issues event
+ {
+ "issue matches",
+ GithubEventIssues,
+ webhook_module.HookEventIssueLabel,
+ true,
+ },
+ {
+ "issue cannot match",
+ GithubEventIssues,
+ webhook_module.HookEventIssueComment,
+ false,
+ },
+ // issue_comment event
+ {
+ "issue_comment matches",
+ GithubEventIssueComment,
+ webhook_module.HookEventIssueComment,
+ true,
+ },
+ {
+ "issue_comment cannot match",
+ GithubEventIssueComment,
+ webhook_module.HookEventIssues,
+ false,
+ },
+ // pull_request event
+ {
+ "pull_request matches",
+ GithubEventPullRequest,
+ webhook_module.HookEventPullRequestSync,
+ true,
+ },
+ {
+ "pull_request cannot match",
+ GithubEventPullRequest,
+ webhook_module.HookEventPullRequestComment,
+ false,
+ },
+ // pull_request_target event
+ {
+ "pull_request_target matches",
+ GithubEventPullRequest,
+ webhook_module.HookEventPullRequest,
+ true,
+ },
+ {
+ "pull_request_target cannot match",
+ GithubEventPullRequest,
+ webhook_module.HookEventPullRequestComment,
+ false,
+ },
+ // pull_request_review event
+ {
+ "pull_request_review matches",
+ GithubEventPullRequestReview,
+ webhook_module.HookEventPullRequestReviewComment,
+ true,
+ },
+ {
+ "pull_request_review cannot match",
+ GithubEventPullRequestReview,
+ webhook_module.HookEventPullRequestComment,
+ false,
+ },
+ // other events
+ {
+ "create event",
+ GithubEventCreate,
+ webhook_module.HookEventCreate,
+ true,
+ },
+ {
+ "create pull request comment",
+ GithubEventIssueComment,
+ webhook_module.HookEventPullRequestComment,
+ true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ assert.Equalf(t, tc.expected, canGithubEventMatch(tc.eventName, tc.triggeredEvent), "canGithubEventMatch(%v, %v)", tc.eventName, tc.triggeredEvent)
+ })
+ }
+}
diff --git a/modules/actions/log.go b/modules/actions/log.go
new file mode 100644
index 0000000..5a1425e
--- /dev/null
+++ b/modules/actions/log.go
@@ -0,0 +1,224 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/dbfs"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/zstd"
+
+ runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+const (
+ MaxLineSize = 64 * 1024
+ DBFSPrefix = "actions_log/"
+
+ timeFormat = "2006-01-02T15:04:05.0000000Z07:00"
+ defaultBufSize = MaxLineSize
+)
+
+// WriteLogs appends logs to DBFS file for temporary storage.
+// It doesn't respect the file format in the filename like ".zst", since it's difficult to reopen a closed compressed file and append new content.
+// Why doesn't it store logs in object storage directly? Because it's not efficient to append content to object storage.
+func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) {
+ flag := os.O_WRONLY
+ if offset == 0 {
+ // Create file only if offset is 0, or it could result in content holes if the file doesn't exist.
+ flag |= os.O_CREATE
+ }
+ name := DBFSPrefix + filename
+ f, err := dbfs.OpenFile(ctx, name, flag)
+ if err != nil {
+ return nil, fmt.Errorf("dbfs OpenFile %q: %w", name, err)
+ }
+ defer f.Close()
+
+ stat, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("dbfs Stat %q: %w", name, err)
+ }
+ if stat.Size() < offset {
+ // If the size is less than offset, refuse to write, or it could result in content holes.
+ // However, if the size is greater than offset, we can still write to overwrite the content.
+ return nil, fmt.Errorf("size of %q is less than offset", name)
+ }
+
+ if _, err := f.Seek(offset, io.SeekStart); err != nil {
+ return nil, fmt.Errorf("dbfs Seek %q: %w", name, err)
+ }
+
+ writer := bufio.NewWriterSize(f, defaultBufSize)
+
+ ns := make([]int, 0, len(rows))
+ for _, row := range rows {
+ n, err := writer.WriteString(FormatLog(row.Time.AsTime(), row.Content) + "\n")
+ if err != nil {
+ return nil, err
+ }
+ ns = append(ns, n)
+ }
+
+ if err := writer.Flush(); err != nil {
+ return nil, err
+ }
+ return ns, nil
+}
+
+func ReadLogs(ctx context.Context, inStorage bool, filename string, offset, limit int64) ([]*runnerv1.LogRow, error) {
+ f, err := OpenLogs(ctx, inStorage, filename)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ if _, err := f.Seek(offset, io.SeekStart); err != nil {
+ return nil, fmt.Errorf("file seek: %w", err)
+ }
+
+ scanner := bufio.NewScanner(f)
+ maxLineSize := len(timeFormat) + MaxLineSize + 1
+ scanner.Buffer(make([]byte, maxLineSize), maxLineSize)
+
+ var rows []*runnerv1.LogRow
+ for scanner.Scan() && (int64(len(rows)) < limit || limit < 0) {
+ t, c, err := ParseLog(scanner.Text())
+ if err != nil {
+ return nil, fmt.Errorf("parse log %q: %w", scanner.Text(), err)
+ }
+ rows = append(rows, &runnerv1.LogRow{
+ Time: timestamppb.New(t),
+ Content: c,
+ })
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("ReadLogs scan: %w", err)
+ }
+
+ return rows, nil
+}
+
+const (
+ // logZstdBlockSize is the block size for zstd compression.
+ // 128KB leads the compression ratio to be close to the regular zstd compression.
+ // And it means each read from the underlying object storage will be at least 128KB*(compression ratio).
+ // The compression ratio is about 30% for text files, so the actual read size is about 38KB, which should be acceptable.
+ logZstdBlockSize = 128 * 1024 // 128KB
+)
+
+// TransferLogs transfers logs from DBFS to object storage.
+// It happens when the file is complete and no more logs will be appended.
+// It respects the file format in the filename like ".zst", and compresses the content if needed.
+func TransferLogs(ctx context.Context, filename string) (func(), error) {
+ name := DBFSPrefix + filename
+ remove := func() {
+ if err := dbfs.Remove(ctx, name); err != nil {
+ log.Warn("dbfs remove %q: %v", name, err)
+ }
+ }
+ f, err := dbfs.Open(ctx, name)
+ if err != nil {
+ return nil, fmt.Errorf("dbfs open %q: %w", name, err)
+ }
+ defer f.Close()
+
+ var reader io.Reader = f
+ if strings.HasSuffix(filename, ".zst") {
+ r, w := io.Pipe()
+ reader = r
+ zstdWriter, err := zstd.NewSeekableWriter(w, logZstdBlockSize)
+ if err != nil {
+ return nil, fmt.Errorf("zstd NewSeekableWriter: %w", err)
+ }
+ go func() {
+ defer func() {
+ _ = w.CloseWithError(zstdWriter.Close())
+ }()
+ if _, err := io.Copy(zstdWriter, f); err != nil {
+ _ = w.CloseWithError(err)
+ return
+ }
+ }()
+ }
+
+ if _, err := storage.Actions.Save(filename, reader, -1); err != nil {
+ return nil, fmt.Errorf("storage save %q: %w", filename, err)
+ }
+ return remove, nil
+}
+
+func RemoveLogs(ctx context.Context, inStorage bool, filename string) error {
+ if !inStorage {
+ name := DBFSPrefix + filename
+ err := dbfs.Remove(ctx, name)
+ if err != nil {
+ return fmt.Errorf("dbfs remove %q: %w", name, err)
+ }
+ return nil
+ }
+ err := storage.Actions.Delete(filename)
+ if err != nil {
+ return fmt.Errorf("storage delete %q: %w", filename, err)
+ }
+ return nil
+}
+
+func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeekCloser, error) {
+ if !inStorage {
+ name := DBFSPrefix + filename
+ f, err := dbfs.Open(ctx, name)
+ if err != nil {
+ return nil, fmt.Errorf("dbfs open %q: %w", name, err)
+ }
+ return f, nil
+ }
+
+ f, err := storage.Actions.Open(filename)
+ if err != nil {
+ return nil, fmt.Errorf("storage open %q: %w", filename, err)
+ }
+
+ var reader io.ReadSeekCloser = f
+ if strings.HasSuffix(filename, ".zst") {
+ r, err := zstd.NewSeekableReader(f)
+ if err != nil {
+ return nil, fmt.Errorf("zstd NewSeekableReader: %w", err)
+ }
+ reader = r
+ }
+
+ return reader, nil
+}
+
+func FormatLog(timestamp time.Time, content string) string {
+ // Content shouldn't contain new line, it will break log indexes, other control chars are safe.
+ content = strings.ReplaceAll(content, "\n", `\n`)
+ if len(content) > MaxLineSize {
+ content = content[:MaxLineSize]
+ }
+ return fmt.Sprintf("%s %s", timestamp.UTC().Format(timeFormat), content)
+}
+
+func ParseLog(in string) (time.Time, string, error) {
+ index := strings.IndexRune(in, ' ')
+ if index < 0 {
+ return time.Time{}, "", fmt.Errorf("invalid log: %q", in)
+ }
+ timestamp, err := time.Parse(timeFormat, in[:index])
+ if err != nil {
+ return time.Time{}, "", err
+ }
+ return timestamp, in[index+1:], nil
+}
diff --git a/modules/actions/task_state.go b/modules/actions/task_state.go
new file mode 100644
index 0000000..1f36e02
--- /dev/null
+++ b/modules/actions/task_state.go
@@ -0,0 +1,123 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ actions_model "code.gitea.io/gitea/models/actions"
+)
+
+const (
+ preStepName = "Set up job"
+ postStepName = "Complete job"
+)
+
+// FullSteps returns steps with "Set up job" and "Complete job"
+func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
+ if len(task.Steps) == 0 {
+ return fullStepsOfEmptySteps(task)
+ }
+
+ // firstStep is the first step that has run or running, not include preStep.
+ // For example,
+ // 1. preStep(Success) -> step1(Success) -> step2(Running) -> step3(Waiting) -> postStep(Waiting): firstStep is step1.
+ // 2. preStep(Success) -> step1(Skipped) -> step2(Success) -> postStep(Success): firstStep is step2.
+ // 3. preStep(Success) -> step1(Running) -> step2(Waiting) -> postStep(Waiting): firstStep is step1.
+ // 4. preStep(Success) -> step1(Skipped) -> step2(Skipped) -> postStep(Skipped): firstStep is nil.
+ // 5. preStep(Success) -> step1(Cancelled) -> step2(Cancelled) -> postStep(Cancelled): firstStep is nil.
+ var firstStep *actions_model.ActionTaskStep
+ // lastHasRunStep is the last step that has run.
+ // For example,
+ // 1. preStep(Success) -> step1(Success) -> step2(Running) -> step3(Waiting) -> postStep(Waiting): lastHasRunStep is step1.
+ // 2. preStep(Success) -> step1(Success) -> step2(Success) -> step3(Success) -> postStep(Success): lastHasRunStep is step3.
+ // 3. preStep(Success) -> step1(Success) -> step2(Failure) -> step3 -> postStep(Waiting): lastHasRunStep is step2.
+ // So its Stopped is the Started of postStep when there are no more steps to run.
+ var lastHasRunStep *actions_model.ActionTaskStep
+
+ var logIndex int64
+ for _, step := range task.Steps {
+ if firstStep == nil && (step.Status.HasRun() || step.Status.IsRunning()) {
+ firstStep = step
+ }
+ if step.Status.HasRun() {
+ lastHasRunStep = step
+ }
+ logIndex += step.LogLength
+ }
+
+ preStep := &actions_model.ActionTaskStep{
+ Name: preStepName,
+ LogLength: task.LogLength,
+ Started: task.Started,
+ Status: actions_model.StatusRunning,
+ }
+
+ // No step has run or is running, so preStep is equal to the task
+ if firstStep == nil {
+ preStep.Stopped = task.Stopped
+ preStep.Status = task.Status
+ } else {
+ preStep.LogLength = firstStep.LogIndex
+ preStep.Stopped = firstStep.Started
+ preStep.Status = actions_model.StatusSuccess
+ }
+ logIndex += preStep.LogLength
+
+ if lastHasRunStep == nil {
+ lastHasRunStep = preStep
+ }
+
+ postStep := &actions_model.ActionTaskStep{
+ Name: postStepName,
+ Status: actions_model.StatusWaiting,
+ }
+ // If the lastHasRunStep is the last step, or it has failed, postStep has started.
+ if lastHasRunStep.Status.IsFailure() || lastHasRunStep == task.Steps[len(task.Steps)-1] {
+ postStep.LogIndex = logIndex
+ postStep.LogLength = task.LogLength - postStep.LogIndex
+ postStep.Started = lastHasRunStep.Stopped
+ postStep.Status = actions_model.StatusRunning
+ }
+ if task.Status.IsDone() {
+ postStep.Status = task.Status
+ postStep.Stopped = task.Stopped
+ }
+ ret := make([]*actions_model.ActionTaskStep, 0, len(task.Steps)+2)
+ ret = append(ret, preStep)
+ ret = append(ret, task.Steps...)
+ ret = append(ret, postStep)
+
+ return ret
+}
+
+func fullStepsOfEmptySteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
+ preStep := &actions_model.ActionTaskStep{
+ Name: preStepName,
+ LogLength: task.LogLength,
+ Started: task.Started,
+ Stopped: task.Stopped,
+ Status: actions_model.StatusRunning,
+ }
+
+ postStep := &actions_model.ActionTaskStep{
+ Name: postStepName,
+ LogIndex: task.LogLength,
+ Started: task.Stopped,
+ Stopped: task.Stopped,
+ Status: actions_model.StatusWaiting,
+ }
+
+ if task.Status.IsDone() {
+ preStep.Status = task.Status
+ if preStep.Status.IsSuccess() {
+ postStep.Status = actions_model.StatusSuccess
+ } else {
+ postStep.Status = actions_model.StatusCancelled
+ }
+ }
+
+ return []*actions_model.ActionTaskStep{
+ preStep,
+ postStep,
+ }
+}
diff --git a/modules/actions/task_state_test.go b/modules/actions/task_state_test.go
new file mode 100644
index 0000000..ff0fd57
--- /dev/null
+++ b/modules/actions/task_state_test.go
@@ -0,0 +1,165 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFullSteps(t *testing.T) {
+ tests := []struct {
+ name string
+ task *actions_model.ActionTask
+ want []*actions_model.ActionTaskStep
+ }{
+ {
+ name: "regular",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ },
+ Status: actions_model.StatusSuccess,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ {Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
+ },
+ },
+ {
+ name: "failed step",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
+ {Status: actions_model.StatusFailure, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
+ {Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ },
+ Status: actions_model.StatusFailure,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
+ {Status: actions_model.StatusFailure, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
+ {Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Name: postStepName, Status: actions_model.StatusFailure, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
+ },
+ },
+ {
+ name: "first step is running",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 0},
+ },
+ Status: actions_model.StatusRunning,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
+ {Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 0},
+ {Name: postStepName, Status: actions_model.StatusWaiting, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ },
+ },
+ {
+ name: "first step has canceled",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ },
+ Status: actions_model.StatusFailure,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusFailure, LogIndex: 0, LogLength: 100, Started: 10000, Stopped: 10100},
+ {Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Name: postStepName, Status: actions_model.StatusFailure, LogIndex: 100, LogLength: 0, Started: 10100, Stopped: 10100},
+ },
+ },
+ {
+ name: "empty steps",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{},
+ Status: actions_model.StatusSuccess,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 100, Started: 10000, Stopped: 10100},
+ {Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 100, LogLength: 0, Started: 10100, Stopped: 10100},
+ },
+ },
+ {
+ name: "all steps finished but task is running",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ },
+ Status: actions_model.StatusRunning,
+ Started: 10000,
+ Stopped: 0,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ {Name: postStepName, Status: actions_model.StatusRunning, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 0},
+ },
+ },
+ {
+ name: "skipped task",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ },
+ Status: actions_model.StatusSkipped,
+ Started: 0,
+ Stopped: 0,
+ LogLength: 0,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Name: postStepName, Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ },
+ },
+ {
+ name: "first step is skipped",
+ task: &actions_model.ActionTask{
+ Steps: []*actions_model.ActionTaskStep{
+ {Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ },
+ Status: actions_model.StatusSuccess,
+ Started: 10000,
+ Stopped: 10100,
+ LogLength: 100,
+ },
+ want: []*actions_model.ActionTaskStep{
+ {Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
+ {Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
+ {Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090},
+ {Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, FullSteps(tt.task), "FullSteps(%v)", tt.task)
+ })
+ }
+}
diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
new file mode 100644
index 0000000..94c221e
--- /dev/null
+++ b/modules/actions/workflows.go
@@ -0,0 +1,702 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "bytes"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/gobwas/glob"
+ "github.com/nektos/act/pkg/jobparser"
+ "github.com/nektos/act/pkg/model"
+ "github.com/nektos/act/pkg/workflowpattern"
+ "gopkg.in/yaml.v3"
+)
+
+type DetectedWorkflow struct {
+ EntryName string
+ TriggerEvent *jobparser.Event
+ Content []byte
+}
+
+func init() {
+ model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
+ // Log the error instead of panic or fatal.
+ // It will be a big job to refactor act/pkg/model to return decode error,
+ // so we just log the error and return empty value, and improve it later.
+ log.Error("Failed to decode node %v into %T: %v", node, out, err)
+ }
+}
+
+func IsWorkflow(path string) bool {
+ if (!strings.HasSuffix(path, ".yaml")) && (!strings.HasSuffix(path, ".yml")) {
+ return false
+ }
+
+ return strings.HasPrefix(path, ".forgejo/workflows") || strings.HasPrefix(path, ".gitea/workflows") || strings.HasPrefix(path, ".github/workflows")
+}
+
+func ListWorkflows(commit *git.Commit) (git.Entries, error) {
+ tree, err := commit.SubTree(".forgejo/workflows")
+ if _, ok := err.(git.ErrNotExist); ok {
+ tree, err = commit.SubTree(".gitea/workflows")
+ }
+ if _, ok := err.(git.ErrNotExist); ok {
+ tree, err = commit.SubTree(".github/workflows")
+ }
+ if _, ok := err.(git.ErrNotExist); ok {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ entries, err := tree.ListEntriesRecursiveFast()
+ if err != nil {
+ return nil, err
+ }
+
+ ret := make(git.Entries, 0, len(entries))
+ for _, entry := range entries {
+ if strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml") {
+ ret = append(ret, entry)
+ }
+ }
+ return ret, nil
+}
+
+func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) {
+ f, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil, err
+ }
+ content, err := io.ReadAll(f)
+ _ = f.Close()
+ if err != nil {
+ return nil, err
+ }
+ return content, nil
+}
+
+func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
+ workflow, err := model.ReadWorkflow(bytes.NewReader(content))
+ if err != nil {
+ return nil, err
+ }
+ events, err := jobparser.ParseRawOn(&workflow.RawOn)
+ if err != nil {
+ return nil, err
+ }
+
+ return events, nil
+}
+
+func DetectWorkflows(
+ gitRepo *git.Repository,
+ commit *git.Commit,
+ triggedEvent webhook_module.HookEventType,
+ payload api.Payloader,
+ detectSchedule bool,
+) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
+ entries, err := ListWorkflows(commit)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ workflows := make([]*DetectedWorkflow, 0, len(entries))
+ schedules := make([]*DetectedWorkflow, 0, len(entries))
+ for _, entry := range entries {
+ content, err := GetContentFromEntry(entry)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // one workflow may have multiple events
+ events, err := GetEventsFromContent(content)
+ if err != nil {
+ log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
+ continue
+ }
+ for _, evt := range events {
+ log.Trace("detect workflow %q for event %#v matching %q", entry.Name(), evt, triggedEvent)
+ if evt.IsSchedule() {
+ if detectSchedule {
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ schedules = append(schedules, dwf)
+ }
+ } else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ workflows = append(workflows, dwf)
+ }
+ }
+ }
+
+ return workflows, schedules, nil
+}
+
+func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
+ entries, err := ListWorkflows(commit)
+ if err != nil {
+ return nil, err
+ }
+
+ wfs := make([]*DetectedWorkflow, 0, len(entries))
+ for _, entry := range entries {
+ content, err := GetContentFromEntry(entry)
+ if err != nil {
+ return nil, err
+ }
+
+ // one workflow may have multiple events
+ events, err := GetEventsFromContent(content)
+ if err != nil {
+ log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
+ continue
+ }
+ for _, evt := range events {
+ if evt.IsSchedule() {
+ log.Trace("detect scheduled workflow: %q", entry.Name())
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ wfs = append(wfs, dwf)
+ }
+ }
+ }
+
+ return wfs, nil
+}
+
+func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool {
+ if !canGithubEventMatch(evt.Name, triggedEvent) {
+ return false
+ }
+
+ switch triggedEvent {
+ case // events with no activity types
+ webhook_module.HookEventWorkflowDispatch,
+ webhook_module.HookEventCreate,
+ webhook_module.HookEventDelete,
+ webhook_module.HookEventFork,
+ webhook_module.HookEventWiki,
+ webhook_module.HookEventSchedule:
+ if len(evt.Acts()) != 0 {
+ log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
+ }
+ // no special filter parameters for these events, just return true if name matched
+ return true
+
+ case // push
+ webhook_module.HookEventPush:
+ return matchPushEvent(commit, payload.(*api.PushPayload), evt)
+
+ case // issues
+ webhook_module.HookEventIssues,
+ webhook_module.HookEventIssueAssign,
+ webhook_module.HookEventIssueLabel,
+ webhook_module.HookEventIssueMilestone:
+ return matchIssuesEvent(payload.(*api.IssuePayload), evt)
+
+ case // issue_comment
+ webhook_module.HookEventIssueComment,
+ // `pull_request_comment` is same as `issue_comment`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
+ webhook_module.HookEventPullRequestComment:
+ return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
+
+ case // pull_request
+ webhook_module.HookEventPullRequest,
+ webhook_module.HookEventPullRequestSync,
+ webhook_module.HookEventPullRequestAssign,
+ webhook_module.HookEventPullRequestLabel,
+ webhook_module.HookEventPullRequestReviewRequest,
+ webhook_module.HookEventPullRequestMilestone:
+ return matchPullRequestEvent(gitRepo, commit, payload.(*api.PullRequestPayload), evt)
+
+ case // pull_request_review
+ webhook_module.HookEventPullRequestReviewApproved,
+ webhook_module.HookEventPullRequestReviewRejected:
+ return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
+
+ case // pull_request_review_comment
+ webhook_module.HookEventPullRequestReviewComment:
+ return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
+
+ case // release
+ webhook_module.HookEventRelease:
+ return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
+
+ case // registry_package
+ webhook_module.HookEventPackage:
+ return matchPackageEvent(payload.(*api.PackagePayload), evt)
+
+ default:
+ log.Warn("unsupported event %q", triggedEvent)
+ return false
+ }
+}
+
+func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ hasBranchFilter := false
+ hasTagFilter := false
+ refName := git.RefName(pushPayload.Ref)
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "branches":
+ hasBranchFilter = true
+ if !refName.IsBranch() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "branches-ignore":
+ hasBranchFilter = true
+ if !refName.IsBranch() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "tags":
+ hasTagFilter = true
+ if !refName.IsTag() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "tags-ignore":
+ hasTagFilter = true
+ if !refName.IsTag() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "paths":
+ filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ case "paths-ignore":
+ filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ default:
+ log.Warn("push event unsupported condition %q", cond)
+ }
+ }
+ // if both branch and tag filter are defined in the workflow only one needs to match
+ if hasBranchFilter && hasTagFilter {
+ matchTimes++
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues
+ // Actions with the same name:
+ // opened, edited, closed, reopened, assigned, unassigned, milestoned, demilestoned
+ // Actions need to be converted:
+ // label_updated -> labeled
+ // label_cleared -> unlabeled
+ // Unsupported activity types:
+ // deleted, transferred, pinned, unpinned, locked, unlocked
+
+ action := issuePayload.Action
+ switch action {
+ case api.HookIssueLabelUpdated:
+ action = "labeled"
+ case api.HookIssueLabelCleared:
+ action = "unlabeled"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("issue event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ acts := evt.Acts()
+ activityTypeMatched := false
+ matchTimes := 0
+
+ if vals, ok := acts["types"]; !ok {
+ // defaultly, only pull request `opened`, `reopened` and `synchronized` will trigger workflow
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
+ activityTypeMatched = prPayload.Action == api.HookIssueSynchronized || prPayload.Action == api.HookIssueOpened || prPayload.Action == api.HookIssueReOpened
+ } else {
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
+ // Actions with the same name:
+ // opened, edited, closed, reopened, assigned, unassigned, review_requested, review_request_removed, milestoned, demilestoned
+ // Actions need to be converted:
+ // synchronized -> synchronize
+ // label_updated -> labeled
+ // label_cleared -> unlabeled
+ // Unsupported activity types:
+ // converted_to_draft, ready_for_review, locked, unlocked, auto_merge_enabled, auto_merge_disabled, enqueued, dequeued
+
+ action := prPayload.Action
+ switch action {
+ case api.HookIssueSynchronized:
+ action = "synchronize"
+ case api.HookIssueLabelUpdated:
+ action = "labeled"
+ case api.HookIssueLabelCleared:
+ action = "unlabeled"
+ }
+ log.Trace("matching pull_request %s with %v", action, vals)
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ activityTypeMatched = true
+ matchTimes++
+ break
+ }
+ }
+ }
+
+ var (
+ headCommit = commit
+ err error
+ )
+ if evt.Name == GithubEventPullRequestTarget && (len(acts["paths"]) > 0 || len(acts["paths-ignore"]) > 0) {
+ headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
+ if err != nil {
+ log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
+ return false
+ }
+ }
+
+ // all acts conditions should be satisfied
+ for cond, vals := range acts {
+ switch cond {
+ case "types":
+ // types have been checked
+ continue
+ case "branches":
+ refName := git.RefName(prPayload.PullRequest.Base.Ref)
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "branches-ignore":
+ refName := git.RefName(prPayload.PullRequest.Base.Ref)
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "paths":
+ filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ case "paths-ignore":
+ filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ default:
+ log.Warn("pull request event unsupported condition %q", cond)
+ }
+ }
+ return activityTypeMatched && matchTimes == len(evt.Acts())
+}
+
+func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment
+ // Actions with the same name:
+ // created, edited, deleted
+ // Actions need to be converted:
+ // NONE
+ // Unsupported activity types:
+ // NONE
+
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("issue comment event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // reviewed -> submitted
+ // reviewed -> edited
+ // Unsupported activity types:
+ // dismissed
+
+ actions := make([]string, 0)
+ if prPayload.Action == api.HookIssueReviewed {
+ // the `reviewed` HookIssueAction can match the two activity types: `submitted` and `edited`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
+ actions = append(actions, "submitted", "edited")
+ }
+
+ matched := false
+ for _, val := range vals {
+ for _, action := range actions {
+ if glob.MustCompile(val, '/').Match(action) {
+ matched = true
+ break
+ }
+ }
+ if matched {
+ break
+ }
+ }
+ if matched {
+ matchTimes++
+ }
+ default:
+ log.Warn("pull request review event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // reviewed -> created
+ // reviewed -> edited
+ // Unsupported activity types:
+ // deleted
+
+ actions := make([]string, 0)
+ if prPayload.Action == api.HookIssueReviewed {
+ // the `reviewed` HookIssueAction can match the two activity types: `created` and `edited`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
+ actions = append(actions, "created", "edited")
+ }
+
+ matched := false
+ for _, val := range vals {
+ for _, action := range actions {
+ if glob.MustCompile(val, '/').Match(action) {
+ matched = true
+ break
+ }
+ }
+ if matched {
+ break
+ }
+ }
+ if matched {
+ matchTimes++
+ }
+ default:
+ log.Warn("pull request review comment event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
+ // Activity types with the same name:
+ // published
+ // Activity types need to be converted:
+ // updated -> edited
+ // Unsupported activity types:
+ // unpublished, created, deleted, prereleased, released
+
+ action := payload.Action
+ if action == api.HookReleaseUpdated {
+ action = "edited"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("release event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#registry_package
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // created -> published
+ // Unsupported activity types:
+ // updated
+
+ action := payload.Action
+ if action == api.HookPackageCreated {
+ action = "published"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("package event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go
new file mode 100644
index 0000000..965d01f
--- /dev/null
+++ b/modules/actions/workflows_test.go
@@ -0,0 +1,163 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDetectMatched(t *testing.T) {
+ testCases := []struct {
+ desc string
+ commit *git.Commit
+ triggeredEvent webhook_module.HookEventType
+ payload api.Payloader
+ yamlOn string
+ expected bool
+ }{
+ {
+ desc: "HookEventCreate(create) matches GithubEventCreate(create)",
+ triggeredEvent: webhook_module.HookEventCreate,
+ payload: nil,
+ yamlOn: "on: create",
+ expected: true,
+ },
+ {
+ desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
+ triggeredEvent: webhook_module.HookEventIssues,
+ payload: &api.IssuePayload{Action: api.HookIssueOpened},
+ yamlOn: "on: issues",
+ expected: true,
+ },
+ {
+ desc: "HookEventIssueComment(issue_comment) `created` action matches GithubEventIssueComment(issue_comment)",
+ triggeredEvent: webhook_module.HookEventIssueComment,
+ payload: &api.IssueCommentPayload{Action: api.HookIssueCommentCreated},
+ yamlOn: "on:\n issue_comment:\n types: [created]",
+ expected: true,
+ },
+
+ {
+ desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
+ triggeredEvent: webhook_module.HookEventIssues,
+ payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
+ yamlOn: "on: issues",
+ expected: true,
+ },
+
+ {
+ desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
+ triggeredEvent: webhook_module.HookEventPullRequestSync,
+ payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
+ yamlOn: "on: pull_request",
+ expected: true,
+ },
+ {
+ desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
+ triggeredEvent: webhook_module.HookEventPullRequest,
+ payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
+ yamlOn: "on: pull_request",
+ expected: false,
+ },
+ {
+ desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
+ triggeredEvent: webhook_module.HookEventPullRequest,
+ payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
+ yamlOn: "on: pull_request",
+ expected: false,
+ },
+ {
+ desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
+ triggeredEvent: webhook_module.HookEventPullRequest,
+ payload: &api.PullRequestPayload{
+ Action: api.HookIssueClosed,
+ PullRequest: &api.PullRequest{
+ Base: &api.PRBranchInfo{},
+ },
+ },
+ yamlOn: "on:\n pull_request:\n branches: [main]",
+ expected: false,
+ },
+ {
+ desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
+ triggeredEvent: webhook_module.HookEventPullRequest,
+ payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
+ yamlOn: "on:\n pull_request:\n types: [labeled]",
+ expected: true,
+ },
+ {
+ desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
+ triggeredEvent: webhook_module.HookEventPullRequestReviewComment,
+ payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
+ yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
+ expected: true,
+ },
+ {
+ desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
+ triggeredEvent: webhook_module.HookEventPullRequestReviewRejected,
+ payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
+ yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
+ expected: false,
+ },
+ {
+ desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
+ triggeredEvent: webhook_module.HookEventRelease,
+ payload: &api.ReleasePayload{Action: api.HookReleasePublished},
+ yamlOn: "on:\n release:\n types: [published]",
+ expected: true,
+ },
+ {
+ desc: "HookEventRelease(updated) `updated` action matches GithubEventRelease(edited) with `edited` activity type",
+ triggeredEvent: webhook_module.HookEventRelease,
+ payload: &api.ReleasePayload{Action: api.HookReleaseUpdated},
+ yamlOn: "on:\n release:\n types: [edited]",
+ expected: true,
+ },
+
+ {
+ desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
+ triggeredEvent: webhook_module.HookEventPackage,
+ payload: &api.PackagePayload{Action: api.HookPackageCreated},
+ yamlOn: "on:\n registry_package:\n types: [updated]",
+ expected: false,
+ },
+ {
+ desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
+ triggeredEvent: webhook_module.HookEventWiki,
+ payload: nil,
+ yamlOn: "on: gollum",
+ expected: true,
+ },
+ {
+ desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
+ triggeredEvent: webhook_module.HookEventSchedule,
+ payload: nil,
+ yamlOn: "on: schedule",
+ expected: true,
+ },
+ {
+ desc: "HookEventWorkflowDispatch(workflow_dispatch) matches GithubEventWorkflowDispatch(workflow_dispatch)",
+ triggeredEvent: webhook_module.HookEventWorkflowDispatch,
+ payload: nil,
+ yamlOn: "on: workflow_dispatch",
+ expected: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ evts, err := GetEventsFromContent([]byte(tc.yamlOn))
+ require.NoError(t, err)
+ assert.Len(t, evts, 1)
+ assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggeredEvent, tc.payload, evts[0]))
+ })
+ }
+}
diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go
new file mode 100644
index 0000000..064d898
--- /dev/null
+++ b/modules/activitypub/client.go
@@ -0,0 +1,273 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// TODO: Think about whether this should be moved to services/activitypub (compare to exosy/services/activitypub/client.go)
+package activitypub
+
+import (
+ "bytes"
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/go-fed/httpsig"
+)
+
+const (
+ // ActivityStreamsContentType const
+ ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
+ httpsigExpirationTime = 60
+)
+
+func CurrentTime() string {
+ return time.Now().UTC().Format(http.TimeFormat)
+}
+
+func containsRequiredHTTPHeaders(method string, headers []string) error {
+ var hasRequestTarget, hasDate, hasDigest, hasHost bool
+ for _, header := range headers {
+ hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
+ hasDate = hasDate || header == "Date"
+ hasDigest = hasDigest || header == "Digest"
+ hasHost = hasHost || header == "Host"
+ }
+ if !hasRequestTarget {
+ return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget)
+ } else if !hasDate {
+ return fmt.Errorf("missing http header for %s: Date", method)
+ } else if !hasHost {
+ return fmt.Errorf("missing http header for %s: Host", method)
+ } else if !hasDigest && method != http.MethodGet {
+ return fmt.Errorf("missing http header for %s: Digest", method)
+ }
+ return nil
+}
+
+// Client struct
+type ClientFactory struct {
+ client *http.Client
+ algs []httpsig.Algorithm
+ digestAlg httpsig.DigestAlgorithm
+ getHeaders []string
+ postHeaders []string
+}
+
+// NewClient function
+func NewClientFactory() (c *ClientFactory, err error) {
+ if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil {
+ return nil, err
+ } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil {
+ return nil, err
+ }
+
+ c = &ClientFactory{
+ client: &http.Client{
+ Transport: &http.Transport{
+ Proxy: proxy.Proxy(),
+ },
+ Timeout: 5 * time.Second,
+ },
+ algs: setting.HttpsigAlgs,
+ digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm),
+ getHeaders: setting.Federation.GetHeaders,
+ postHeaders: setting.Federation.PostHeaders,
+ }
+ return c, err
+}
+
+type APClientFactory interface {
+ WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error)
+}
+
+// Client struct
+type Client struct {
+ client *http.Client
+ algs []httpsig.Algorithm
+ digestAlg httpsig.DigestAlgorithm
+ getHeaders []string
+ postHeaders []string
+ priv *rsa.PrivateKey
+ pubID string
+}
+
+// NewRequest function
+func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) {
+ priv, err := GetPrivateKey(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ privPem, _ := pem.Decode([]byte(priv))
+ privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
+ if err != nil {
+ return nil, err
+ }
+
+ c := Client{
+ client: cf.client,
+ algs: cf.algs,
+ digestAlg: cf.digestAlg,
+ getHeaders: cf.getHeaders,
+ postHeaders: cf.postHeaders,
+ priv: privParsed,
+ pubID: pubID,
+ }
+ return &c, nil
+}
+
+// NewRequest function
+func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
+ buf := bytes.NewBuffer(b)
+ req, err = http.NewRequest(method, to, buf)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Accept", "application/json, "+ActivityStreamsContentType)
+ req.Header.Add("Date", CurrentTime())
+ req.Header.Add("Host", req.URL.Host)
+ req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
+ req.Header.Add("Content-Type", ActivityStreamsContentType)
+
+ return req, err
+}
+
+// Post function
+func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
+ var req *http.Request
+ if req, err = c.newRequest(http.MethodPost, b, to); err != nil {
+ return nil, err
+ }
+
+ signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
+ if err != nil {
+ return nil, err
+ }
+ if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil {
+ return nil, err
+ }
+
+ resp, err = c.client.Do(req)
+ return resp, err
+}
+
+// Create an http GET request with forgejo/gitea specific headers
+func (c *Client) Get(to string) (resp *http.Response, err error) {
+ var req *http.Request
+ if req, err = c.newRequest(http.MethodGet, nil, to); err != nil {
+ return nil, err
+ }
+ signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime)
+ if err != nil {
+ return nil, err
+ }
+ if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil {
+ return nil, err
+ }
+
+ resp, err = c.client.Do(req)
+ return resp, err
+}
+
+// Create an http GET request with forgejo/gitea specific headers
+func (c *Client) GetBody(uri string) ([]byte, error) {
+ response, err := c.Get(uri)
+ if err != nil {
+ return nil, err
+ }
+ log.Debug("Client: got status: %v", response.Status)
+ if response.StatusCode != 200 {
+ err = fmt.Errorf("got non 200 status code for id: %v", uri)
+ return nil, err
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, err
+ }
+ log.Debug("Client: got body: %v", charLimiter(string(body), 120))
+ return body, nil
+}
+
+// Limit number of characters in a string (useful to prevent log injection attacks and overly long log outputs)
+// Thanks to https://www.socketloop.com/tutorials/golang-characters-limiter-example
+func charLimiter(s string, limit int) string {
+ reader := strings.NewReader(s)
+ buff := make([]byte, limit)
+ n, _ := io.ReadAtLeast(reader, buff, limit)
+ if n != 0 {
+ return fmt.Sprint(string(buff), "...")
+ }
+ return s
+}
+
+type APClient interface {
+ newRequest(method string, b []byte, to string) (req *http.Request, err error)
+ Post(b []byte, to string) (resp *http.Response, err error)
+ Get(to string) (resp *http.Response, err error)
+ GetBody(uri string) ([]byte, error)
+}
+
+// contextKey is a value for use with context.WithValue.
+type contextKey struct {
+ name string
+}
+
+// clientFactoryContextKey is a context key. It is used with context.Value() to get the current Food for the context
+var (
+ clientFactoryContextKey = &contextKey{"clientFactory"}
+ _ APClientFactory = &ClientFactory{}
+)
+
+// Context represents an activitypub client factory context
+type Context struct {
+ context.Context
+ e APClientFactory
+}
+
+func NewContext(ctx context.Context, e APClientFactory) *Context {
+ return &Context{
+ Context: ctx,
+ e: e,
+ }
+}
+
+// APClientFactory represents an activitypub client factory
+func (ctx *Context) APClientFactory() APClientFactory {
+ return ctx.e
+}
+
+// provides APClientFactory
+type GetAPClient interface {
+ GetClientFactory() APClientFactory
+}
+
+// GetClientFactory will get an APClientFactory from this context or returns the default implementation
+func GetClientFactory(ctx context.Context) (APClientFactory, error) {
+ if e := getClientFactory(ctx); e != nil {
+ return e, nil
+ }
+ return NewClientFactory()
+}
+
+// getClientFactory will get an APClientFactory from this context or return nil
+func getClientFactory(ctx context.Context) APClientFactory {
+ if clientFactory, ok := ctx.(APClientFactory); ok {
+ return clientFactory
+ }
+ clientFactoryInterface := ctx.Value(clientFactoryContextKey)
+ if clientFactoryInterface != nil {
+ return clientFactoryInterface.(GetAPClient).GetClientFactory()
+ }
+ return nil
+}
diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go
new file mode 100644
index 0000000..647a0a5
--- /dev/null
+++ b/modules/activitypub/client_test.go
@@ -0,0 +1,138 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCurrentTime(t *testing.T) {
+ date := CurrentTime()
+ _, err := time.Parse(http.TimeFormat, date)
+ require.NoError(t, err)
+ assert.Equal(t, "GMT", date[len(date)-3:])
+}
+
+/* ToDo: Set Up tests for http get requests
+
+Set up an expected response for GET on api with user-id = 1:
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1"
+ ],
+ "id": "http://localhost:3000/api/v1/activitypub/user-id/1",
+ "type": "Person",
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "http://localhost:3000/avatar/3120fd0edc57d5d41230013ad88232e2"
+ },
+ "url": "http://localhost:3000/me",
+ "inbox": "http://localhost:3000/api/v1/activitypub/user-id/1/inbox",
+ "outbox": "http://localhost:3000/api/v1/activitypub/user-id/1/outbox",
+ "preferredUsername": "me",
+ "publicKey": {
+ "id": "http://localhost:3000/api/v1/activitypub/user-id/1#main-key",
+ "owner": "http://localhost:3000/api/v1/activitypub/user-id/1",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAo1VDZGWQBDTWKhpWiPQp\n7nD94UsKkcoFwDQVuxE3bMquKEHBomB4cwUnVou922YkL3AmSOr1sX2yJQGqnCLm\nOeKS74/mCIAoYlu0d75bqY4A7kE2VrQmQLZBbmpCTfrPqDaE6Mfm/kXaX7+hsrZS\n4bVvzZCYq8sjtRxdPk+9ku2QhvznwTRlWLvwHmFSGtlQYPRu+f/XqoVM/DVRA/Is\nwDk9yiNIecV+Isus0CBq1jGQkfuVNu1GK2IvcSg9MoDm3VH/tCayAP+xWm0g7sC8\nKay6Y/khvTvE7bWEKGQsJGvi3+4wITLVLVt+GoVOuCzdbhTV2CHBzn7h30AoZD0N\nY6eyb+Q142JykoHadcRwh1a36wgoG7E496wPvV3ST8xdiClca8cDNhOzCj8woY+t\nTFCMl32U3AJ4e/cAsxKRocYLZqc95dDqdNQiIyiRMMkf5NaA/QvelY4PmFuHC0WR\nVuJ4A3mcti2QLS9j0fSwSJdlfolgW6xaPgjdvuSQsgX1AgMBAAE=\n-----END PUBLIC KEY-----\n"
+ }
+}
+
+Set up a user called "me" for all tests
+
+
+
+*/
+
+func TestClientCtx(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pubID := "myGpgId"
+ cf, err := NewClientFactory()
+ log.Debug("ClientFactory: %v\nError: %v", cf, err)
+ require.NoError(t, err)
+
+ c, err := cf.WithKeys(db.DefaultContext, user, pubID)
+
+ log.Debug("Client: %v\nError: %v", c, err)
+ require.NoError(t, err)
+ _ = NewContext(db.DefaultContext, cf)
+}
+
+/* TODO: bring this test to work or delete
+func TestActivityPubSignedGet(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1, Name: "me"})
+ pubID := "myGpgId"
+ c, err := NewClient(db.DefaultContext, user, pubID)
+ require.NoError(t, err)
+
+ expected := "TestActivityPubSignedGet"
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest"))
+ assert.Contains(t, r.Header.Get("Signature"), pubID)
+ assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType)
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+ fmt.Fprint(w, expected)
+ }))
+ defer srv.Close()
+
+ r, err := c.Get(srv.URL)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+
+}
+*/
+
+func TestActivityPubSignedPost(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pubID := "https://example.com/pubID"
+ cf, err := NewClientFactory()
+ require.NoError(t, err)
+ c, err := cf.WithKeys(db.DefaultContext, user, pubID)
+ require.NoError(t, err)
+
+ expected := "BODY"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest"))
+ assert.Contains(t, r.Header.Get("Signature"), pubID)
+ assert.Equal(t, ActivityStreamsContentType, r.Header.Get("Content-Type"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+ fmt.Fprint(w, expected)
+ }))
+ defer srv.Close()
+
+ r, err := c.Post([]byte(expected), srv.URL)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+}
diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go
new file mode 100644
index 0000000..4591f1f
--- /dev/null
+++ b/modules/activitypub/main_test.go
@@ -0,0 +1,18 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go
new file mode 100644
index 0000000..7f939af
--- /dev/null
+++ b/modules/activitypub/user_settings.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "context"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const rsaBits = 3072
+
+// GetKeyPair function returns a user's private and public keys
+func GetKeyPair(ctx context.Context, user *user_model.User) (pub, priv string, err error) {
+ var settings map[string]*user_model.Setting
+ settings, err = user_model.GetSettings(ctx, user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem})
+ if err != nil {
+ return pub, priv, err
+ } else if len(settings) == 0 {
+ if priv, pub, err = util.GenerateKeyPair(rsaBits); err != nil {
+ return pub, priv, err
+ }
+ if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPrivPem, priv); err != nil {
+ return pub, priv, err
+ }
+ if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPubPem, pub); err != nil {
+ return pub, priv, err
+ }
+ return pub, priv, err
+ }
+ priv = settings[user_model.UserActivityPubPrivPem].SettingValue
+ pub = settings[user_model.UserActivityPubPubPem].SettingValue
+ return pub, priv, err
+}
+
+// GetPublicKey function returns a user's public key
+func GetPublicKey(ctx context.Context, user *user_model.User) (pub string, err error) {
+ pub, _, err = GetKeyPair(ctx, user)
+ return pub, err
+}
+
+// GetPrivateKey function returns a user's private key
+func GetPrivateKey(ctx context.Context, user *user_model.User) (priv string, err error) {
+ _, priv, err = GetKeyPair(ctx, user)
+ return priv, err
+}
diff --git a/modules/activitypub/user_settings_test.go b/modules/activitypub/user_settings_test.go
new file mode 100644
index 0000000..f510e7a
--- /dev/null
+++ b/modules/activitypub/user_settings_test.go
@@ -0,0 +1,30 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ _ "code.gitea.io/gitea/models" // https://forum.gitea.com/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUserSettings(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pub, priv, err := GetKeyPair(db.DefaultContext, user1)
+ require.NoError(t, err)
+ pub1, err := GetPublicKey(db.DefaultContext, user1)
+ require.NoError(t, err)
+ assert.Equal(t, pub, pub1)
+ priv1, err := GetPrivateKey(db.DefaultContext, user1)
+ require.NoError(t, err)
+ assert.Equal(t, priv, priv1)
+}
diff --git a/modules/analyze/code_language.go b/modules/analyze/code_language.go
new file mode 100644
index 0000000..74e7a06
--- /dev/null
+++ b/modules/analyze/code_language.go
@@ -0,0 +1,27 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package analyze
+
+import (
+ "path/filepath"
+
+ "github.com/go-enry/go-enry/v2"
+)
+
+// GetCodeLanguage detects code language based on file name and content
+func GetCodeLanguage(filename string, content []byte) string {
+ if language, ok := enry.GetLanguageByExtension(filename); ok {
+ return language
+ }
+
+ if language, ok := enry.GetLanguageByFilename(filename); ok {
+ return language
+ }
+
+ if len(content) == 0 {
+ return enry.OtherLanguage
+ }
+
+ return enry.GetLanguage(filepath.Base(filename), content)
+}
diff --git a/modules/analyze/generated.go b/modules/analyze/generated.go
new file mode 100644
index 0000000..f608387
--- /dev/null
+++ b/modules/analyze/generated.go
@@ -0,0 +1,27 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package analyze
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/go-enry/go-enry/v2/data"
+)
+
+// IsGenerated returns whether or not path is a generated path.
+func IsGenerated(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ if _, ok := data.GeneratedCodeExtensions[ext]; ok {
+ return true
+ }
+
+ for _, m := range data.GeneratedCodeNameMatchers {
+ if m(path) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/modules/analyze/vendor.go b/modules/analyze/vendor.go
new file mode 100644
index 0000000..adcca92
--- /dev/null
+++ b/modules/analyze/vendor.go
@@ -0,0 +1,13 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package analyze
+
+import (
+ "github.com/go-enry/go-enry/v2"
+)
+
+// IsVendor returns whether or not path is a vendor path.
+func IsVendor(path string) bool {
+ return enry.IsVendor(path)
+}
diff --git a/modules/analyze/vendor_test.go b/modules/analyze/vendor_test.go
new file mode 100644
index 0000000..aafd3c4
--- /dev/null
+++ b/modules/analyze/vendor_test.go
@@ -0,0 +1,41 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package analyze
+
+import "testing"
+
+func TestIsVendor(t *testing.T) {
+ tests := []struct {
+ path string
+ want bool
+ }{
+ {"cache/", true},
+ {"random/cache/", true},
+ {"cache", false},
+ {"dependencies/", true},
+ {"Dependencies/", true},
+ {"dependency/", false},
+ {"dist/", true},
+ {"dist", false},
+ {"random/dist/", true},
+ {"random/dist", false},
+ {"deps/", true},
+ {"configure", true},
+ {"a/configure", true},
+ {"config.guess", true},
+ {"config.guess/", false},
+ {".vscode/", true},
+ {"doc/_build/", true},
+ {"a/docs/_build/", true},
+ {"a/dasdocs/_build-vsdoc.js", true},
+ {"a/dasdocs/_build-vsdoc.j", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ if got := IsVendor(tt.path); got != tt.want {
+ t.Errorf("IsVendor() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go
new file mode 100644
index 0000000..9678d23
--- /dev/null
+++ b/modules/assetfs/layered.go
@@ -0,0 +1,256 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package assetfs
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem
+type Layer struct {
+ name string
+ fs http.FileSystem
+ localPath string
+}
+
+func (l *Layer) Name() string {
+ return l.name
+}
+
+// Open opens the named file. The caller is responsible for closing the file.
+func (l *Layer) Open(name string) (http.File, error) {
+ return l.fs.Open(name)
+}
+
+// Local returns a new Layer with the given name, it serves files from the given local path.
+func Local(name, base string, sub ...string) *Layer {
+ // TODO: the old behavior (StaticRootPath might not be absolute), not ideal, just keep the same as before
+ // Ideally, the caller should guarantee the base is absolute, guessing a relative path based on the current working directory is unreliable.
+ base, err := filepath.Abs(base)
+ if err != nil {
+ // This should never happen in a real system. If it happens, the user must have already been in trouble: the system is not able to resolve its own paths.
+ panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err))
+ }
+ root := util.FilePathJoinAbs(base, sub...)
+ return &Layer{name: name, fs: http.Dir(root), localPath: root}
+}
+
+// Bindata returns a new Layer with the given name, it serves files from the given bindata asset.
+func Bindata(name string, fs http.FileSystem) *Layer {
+ return &Layer{name: name, fs: fs}
+}
+
+// LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers.
+// The first layer is the top layer, and it will be used first.
+// If the file is not found in the top layer, it will be searched in the next layer.
+type LayeredFS struct {
+ layers []*Layer
+}
+
+// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
+func Layered(layers ...*Layer) *LayeredFS {
+ return &LayeredFS{layers: layers}
+}
+
+// Open opens the named file. The caller is responsible for closing the file.
+func (l *LayeredFS) Open(name string) (http.File, error) {
+ for _, layer := range l.layers {
+ f, err := layer.Open(name)
+ if err == nil || !os.IsNotExist(err) {
+ return f, err
+ }
+ }
+ return nil, fs.ErrNotExist
+}
+
+// ReadFile reads the named file.
+func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
+ bs, _, err := l.ReadLayeredFile(elems...)
+ return bs, err
+}
+
+// ReadLayeredFile reads the named file, and returns the layer name.
+func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
+ name := util.PathJoinRel(elems...)
+ for _, layer := range l.layers {
+ f, err := layer.Open(name)
+ if os.IsNotExist(err) {
+ continue
+ } else if err != nil {
+ return nil, layer.name, err
+ }
+ bs, err := io.ReadAll(f)
+ _ = f.Close()
+ return bs, layer.name, err
+ }
+ return nil, "", fs.ErrNotExist
+}
+
+func shouldInclude(info fs.FileInfo, fileMode ...bool) bool {
+ if util.CommonSkip(info.Name()) {
+ return false
+ }
+ if len(fileMode) == 0 {
+ return true
+ } else if len(fileMode) == 1 {
+ return fileMode[0] == !info.Mode().IsDir()
+ }
+ panic("too many arguments for fileMode in shouldInclude")
+}
+
+func readDir(layer *Layer, name string) ([]fs.FileInfo, error) {
+ f, err := layer.Open(name)
+ if os.IsNotExist(err) {
+ return nil, nil
+ } else if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return f.Readdir(-1)
+}
+
+// ListFiles lists files/directories in the given directory. The fileMode controls the returned files.
+// * omitted: all files and directories will be returned.
+// * true: only files will be returned.
+// * false: only directories will be returned.
+// The returned files are sorted by name.
+func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) {
+ fileSet := make(container.Set[string])
+ for _, layer := range l.layers {
+ infos, err := readDir(layer, name)
+ if err != nil {
+ return nil, err
+ }
+ for _, info := range infos {
+ if shouldInclude(info, fileMode...) {
+ fileSet.Add(info.Name())
+ }
+ }
+ }
+ files := fileSet.Values()
+ sort.Strings(files)
+ return files, nil
+}
+
+// ListAllFiles returns files/directories in the given directory, including subdirectories, recursively.
+// The fileMode controls the returned files:
+// * omitted: all files and directories will be returned.
+// * true: only files will be returned.
+// * false: only directories will be returned.
+// The returned files are sorted by name.
+func (l *LayeredFS) ListAllFiles(name string, fileMode ...bool) ([]string, error) {
+ return listAllFiles(l.layers, name, fileMode...)
+}
+
+func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, error) {
+ fileSet := make(container.Set[string])
+ var list func(dir string) error
+ list = func(dir string) error {
+ for _, layer := range layers {
+ infos, err := readDir(layer, dir)
+ if err != nil {
+ return err
+ }
+ for _, info := range infos {
+ path := util.PathJoinRelX(dir, info.Name())
+ if shouldInclude(info, fileMode...) {
+ fileSet.Add(path)
+ }
+ if info.IsDir() {
+ if err = list(path); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return nil
+ }
+ if err := list(name); err != nil {
+ return nil, err
+ }
+ files := fileSet.Values()
+ sort.Strings(files)
+ return files, nil
+}
+
+// WatchLocalChanges watches local changes in the file-system. It's used to help to reload assets when the local file-system changes.
+func (l *LayeredFS) WatchLocalChanges(ctx context.Context, callback func()) {
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Asset Local FileSystem Watcher", process.SystemProcessType, true)
+ defer finished()
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Error("Unable to create watcher for asset local file-system: %v", err)
+ return
+ }
+ defer watcher.Close()
+
+ for _, layer := range l.layers {
+ if layer.localPath == "" {
+ continue
+ }
+ layerDirs, err := listAllFiles([]*Layer{layer}, ".", false)
+ if err != nil {
+ log.Error("Unable to list directories for asset local file-system %q: %v", layer.localPath, err)
+ continue
+ }
+ layerDirs = append(layerDirs, ".")
+ for _, dir := range layerDirs {
+ if err = watcher.Add(util.FilePathJoinAbs(layer.localPath, dir)); err != nil && !os.IsNotExist(err) {
+ log.Error("Unable to watch directory %s: %v", dir, err)
+ }
+ }
+ }
+
+ debounce := util.Debounce(100 * time.Millisecond)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ log.Trace("Watched asset local file-system had event: %v", event)
+ debounce(callback)
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ log.Error("Watched asset local file-system had error: %v", err)
+ }
+ }
+}
+
+// GetFileLayerName returns the name of the first-seen layer that contains the given file.
+func (l *LayeredFS) GetFileLayerName(elems ...string) string {
+ name := util.PathJoinRel(elems...)
+ for _, layer := range l.layers {
+ f, err := layer.Open(name)
+ if os.IsNotExist(err) {
+ continue
+ } else if err != nil {
+ return ""
+ }
+ _ = f.Close()
+ return layer.name
+ }
+ return ""
+}
diff --git a/modules/assetfs/layered_test.go b/modules/assetfs/layered_test.go
new file mode 100644
index 0000000..58876d9
--- /dev/null
+++ b/modules/assetfs/layered_test.go
@@ -0,0 +1,110 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package assetfs
+
+import (
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLayered(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "assetfs-layers")
+ dir1 := filepath.Join(dir, "l1")
+ dir2 := filepath.Join(dir, "l2")
+
+ mkdir := func(elems ...string) {
+ require.NoError(t, os.MkdirAll(filepath.Join(elems...), 0o755))
+ }
+ write := func(content string, elems ...string) {
+ require.NoError(t, os.WriteFile(filepath.Join(elems...), []byte(content), 0o644))
+ }
+
+ // d1 & f1: only in "l1"; d2 & f2: only in "l2"
+ // da & fa: in both "l1" and "l2"
+ mkdir(dir1, "d1")
+ mkdir(dir1, "da")
+ mkdir(dir1, "da/sub1")
+
+ mkdir(dir2, "d2")
+ mkdir(dir2, "da")
+ mkdir(dir2, "da/sub2")
+
+ write("dummy", dir1, ".DS_Store")
+ write("f1", dir1, "f1")
+ write("fa-1", dir1, "fa")
+ write("d1-f", dir1, "d1/f")
+ write("da-f-1", dir1, "da/f")
+
+ write("f2", dir2, "f2")
+ write("fa-2", dir2, "fa")
+ write("d2-f", dir2, "d2/f")
+ write("da-f-2", dir2, "da/f")
+
+ assets := Layered(Local("l1", dir1), Local("l2", dir2))
+
+ f, err := assets.Open("f1")
+ require.NoError(t, err)
+ bs, err := io.ReadAll(f)
+ require.NoError(t, err)
+ assert.EqualValues(t, "f1", string(bs))
+ _ = f.Close()
+
+ assertRead := func(expected string, expectedErr error, elems ...string) {
+ bs, err := assets.ReadFile(elems...)
+ if err != nil {
+ require.ErrorIs(t, err, expectedErr)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(bs))
+ }
+ }
+ assertRead("f1", nil, "f1")
+ assertRead("f2", nil, "f2")
+ assertRead("fa-1", nil, "fa")
+
+ assertRead("d1-f", nil, "d1/f")
+ assertRead("d2-f", nil, "d2/f")
+ assertRead("da-f-1", nil, "da/f")
+
+ assertRead("", fs.ErrNotExist, "no-such")
+
+ files, err := assets.ListFiles(".", true)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"f1", "f2", "fa"}, files)
+
+ files, err = assets.ListFiles(".", false)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"d1", "d2", "da"}, files)
+
+ files, err = assets.ListFiles(".")
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"d1", "d2", "da", "f1", "f2", "fa"}, files)
+
+ files, err = assets.ListAllFiles(".", true)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"d1/f", "d2/f", "da/f", "f1", "f2", "fa"}, files)
+
+ files, err = assets.ListAllFiles(".", false)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"d1", "d2", "da", "da/sub1", "da/sub2"}, files)
+
+ files, err = assets.ListAllFiles(".")
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{
+ "d1", "d1/f",
+ "d2", "d2/f",
+ "da", "da/f", "da/sub1", "da/sub2",
+ "f1", "f2", "fa",
+ }, files)
+
+ assert.Empty(t, assets.GetFileLayerName("no-such"))
+ assert.EqualValues(t, "l1", assets.GetFileLayerName("f1"))
+ assert.EqualValues(t, "l2", assets.GetFileLayerName("f2"))
+}
diff --git a/modules/auth/common.go b/modules/auth/common.go
new file mode 100644
index 0000000..77361f6
--- /dev/null
+++ b/modules/auth/common.go
@@ -0,0 +1,22 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+)
+
+func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, error) {
+ groupTeamMapping := make(map[string]map[string][]string)
+ if raw == "" {
+ return groupTeamMapping, nil
+ }
+ err := json.Unmarshal([]byte(raw), &groupTeamMapping)
+ if err != nil {
+ log.Error("Failed to unmarshal group team mapping: %v", err)
+ return nil, err
+ }
+ return groupTeamMapping, nil
+}
diff --git a/modules/auth/openid/discovery_cache.go b/modules/auth/openid/discovery_cache.go
new file mode 100644
index 0000000..3a8d119
--- /dev/null
+++ b/modules/auth/openid/discovery_cache.go
@@ -0,0 +1,57 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package openid
+
+import (
+ "sync"
+ "time"
+
+ "github.com/yohcop/openid-go"
+)
+
+type timedDiscoveredInfo struct {
+ info openid.DiscoveredInfo
+ time time.Time
+}
+
+type timedDiscoveryCache struct {
+ cache map[string]timedDiscoveredInfo
+ ttl time.Duration
+ mutex *sync.Mutex
+}
+
+func newTimedDiscoveryCache(ttl time.Duration) *timedDiscoveryCache {
+ return &timedDiscoveryCache{cache: map[string]timedDiscoveredInfo{}, ttl: ttl, mutex: &sync.Mutex{}}
+}
+
+func (s *timedDiscoveryCache) Put(id string, info openid.DiscoveredInfo) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ s.cache[id] = timedDiscoveredInfo{info: info, time: time.Now()}
+}
+
+// Delete timed-out cache entries
+func (s *timedDiscoveryCache) cleanTimedOut() {
+ now := time.Now()
+ for k, e := range s.cache {
+ diff := now.Sub(e.time)
+ if diff > s.ttl {
+ delete(s.cache, k)
+ }
+ }
+}
+
+func (s *timedDiscoveryCache) Get(id string) openid.DiscoveredInfo {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ // Delete old cached while we are at it.
+ s.cleanTimedOut()
+
+ if info, has := s.cache[id]; has {
+ return info.info
+ }
+ return nil
+}
diff --git a/modules/auth/openid/discovery_cache_test.go b/modules/auth/openid/discovery_cache_test.go
new file mode 100644
index 0000000..5a7f450
--- /dev/null
+++ b/modules/auth/openid/discovery_cache_test.go
@@ -0,0 +1,49 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package openid
+
+import (
+ "testing"
+ "time"
+)
+
+type testDiscoveredInfo struct{}
+
+func (s *testDiscoveredInfo) ClaimedID() string {
+ return "claimedID"
+}
+
+func (s *testDiscoveredInfo) OpEndpoint() string {
+ return "opEndpoint"
+}
+
+func (s *testDiscoveredInfo) OpLocalID() string {
+ return "opLocalID"
+}
+
+func TestTimedDiscoveryCache(t *testing.T) {
+ dc := newTimedDiscoveryCache(1 * time.Second)
+
+ // Put some initial values
+ dc.Put("foo", &testDiscoveredInfo{}) // openid.opEndpoint: "a", openid.opLocalID: "b", openid.claimedID: "c"})
+
+ // Make sure we can retrieve them
+ if di := dc.Get("foo"); di == nil {
+ t.Errorf("Expected a result, got nil")
+ } else if di.OpEndpoint() != "opEndpoint" || di.OpLocalID() != "opLocalID" || di.ClaimedID() != "claimedID" {
+ t.Errorf("Expected opEndpoint opLocalID claimedID, got %v %v %v", di.OpEndpoint(), di.OpLocalID(), di.ClaimedID())
+ }
+
+ // Attempt to get a non-existent value
+ if di := dc.Get("bar"); di != nil {
+ t.Errorf("Expected nil, got %v", di)
+ }
+
+ // Sleep one second and try retrieve again
+ time.Sleep(1 * time.Second)
+
+ if di := dc.Get("foo"); di != nil {
+ t.Errorf("Expected a nil, got a result")
+ }
+}
diff --git a/modules/auth/openid/openid.go b/modules/auth/openid/openid.go
new file mode 100644
index 0000000..249ce02
--- /dev/null
+++ b/modules/auth/openid/openid.go
@@ -0,0 +1,37 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package openid
+
+import (
+ "time"
+
+ "github.com/yohcop/openid-go"
+)
+
+// For the demo, we use in-memory infinite storage nonce and discovery
+// cache. In your app, do not use this as it will eat up memory and
+// never
+// free it. Use your own implementation, on a better database system.
+// If you have multiple servers for example, you may need to share at
+// least
+// the nonceStore between them.
+var (
+ nonceStore = openid.NewSimpleNonceStore()
+ discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
+)
+
+// Verify handles response from OpenID provider
+func Verify(fullURL string) (id string, err error) {
+ return openid.Verify(fullURL, discoveryCache, nonceStore)
+}
+
+// Normalize normalizes an OpenID URI
+func Normalize(url string) (id string, err error) {
+ return openid.Normalize(url)
+}
+
+// RedirectURL redirects browser
+func RedirectURL(id, callbackURL, realm string) (string, error) {
+ return openid.RedirectURL(id, callbackURL, realm)
+}
diff --git a/modules/auth/pam/pam.go b/modules/auth/pam/pam.go
new file mode 100644
index 0000000..cca1482
--- /dev/null
+++ b/modules/auth/pam/pam.go
@@ -0,0 +1,43 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build pam
+
+package pam
+
+import (
+ "errors"
+
+ "github.com/msteinert/pam"
+)
+
+// Supported is true when built with PAM
+var Supported = true
+
+// Auth pam auth service
+func Auth(serviceName, userName, passwd string) (string, error) {
+ t, err := pam.StartFunc(serviceName, userName, func(s pam.Style, msg string) (string, error) {
+ switch s {
+ case pam.PromptEchoOff:
+ return passwd, nil
+ case pam.PromptEchoOn, pam.ErrorMsg, pam.TextInfo:
+ return "", nil
+ }
+ return "", errors.New("Unrecognized PAM message style")
+ })
+ if err != nil {
+ return "", err
+ }
+
+ if err = t.Authenticate(0); err != nil {
+ return "", err
+ }
+
+ if err = t.AcctMgmt(0); err != nil {
+ return "", err
+ }
+
+ // PAM login names might suffer transformations in the PAM stack.
+ // We should take whatever the PAM stack returns for it.
+ return t.GetItem(pam.User)
+}
diff --git a/modules/auth/pam/pam_stub.go b/modules/auth/pam/pam_stub.go
new file mode 100644
index 0000000..3631eee
--- /dev/null
+++ b/modules/auth/pam/pam_stub.go
@@ -0,0 +1,22 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !pam
+
+package pam
+
+import (
+ "errors"
+)
+
+// Supported is false when built without PAM
+var Supported = false
+
+// Auth not supported lack of pam tag
+func Auth(serviceName, userName, passwd string) (string, error) {
+ // bypass the lint on callers: SA4023: this comparison is always true (staticcheck)
+ if !Supported {
+ return "", errors.New("PAM not supported")
+ }
+ return "", nil
+}
diff --git a/modules/auth/pam/pam_test.go b/modules/auth/pam/pam_test.go
new file mode 100644
index 0000000..e9b844e
--- /dev/null
+++ b/modules/auth/pam/pam_test.go
@@ -0,0 +1,20 @@
+//go:build pam
+
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pam
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPamAuth(t *testing.T) {
+ result, err := Auth("gitea", "user1", "false-pwd")
+ require.Error(t, err)
+ assert.EqualError(t, err, "Authentication failure")
+ assert.Len(t, result, 0)
+}
diff --git a/modules/auth/password/hash/argon2.go b/modules/auth/password/hash/argon2.go
new file mode 100644
index 0000000..0cd6472
--- /dev/null
+++ b/modules/auth/password/hash/argon2.go
@@ -0,0 +1,80 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "encoding/hex"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "golang.org/x/crypto/argon2"
+)
+
+func init() {
+ MustRegister("argon2", NewArgon2Hasher)
+}
+
+// Argon2Hasher implements PasswordHasher
+// and uses the Argon2 key derivation function, hybrant variant
+type Argon2Hasher struct {
+ time uint32
+ memory uint32
+ threads uint8
+ keyLen uint32
+}
+
+// HashWithSaltBytes a provided password and salt
+func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string {
+ if hasher == nil {
+ return ""
+ }
+ return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen))
+}
+
+// NewArgon2Hasher is a factory method to create an Argon2Hasher
+// The provided config should be either empty or of the form:
+// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation
+// of an integer
+func NewArgon2Hasher(config string) *Argon2Hasher {
+ // This default configuration uses the following parameters:
+ // time=2, memory=64*1024, threads=8, keyLen=50.
+ // It will make two passes through the memory, using 64MiB in total.
+ // This matches the original configuration for `argon2` prior to storing hash parameters
+ // in the database.
+ // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
+ hasher := &Argon2Hasher{
+ time: 2,
+ memory: 1 << 16,
+ threads: 8,
+ keyLen: 50,
+ }
+
+ if config == "" {
+ return hasher
+ }
+
+ vals := strings.SplitN(config, "$", 4)
+ if len(vals) != 4 {
+ log.Error("invalid argon2 hash spec %s", config)
+ return nil
+ }
+
+ parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil)
+ hasher.time = uint32(parsed)
+
+ parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err)
+ hasher.memory = uint32(parsed)
+
+ parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err)
+ hasher.threads = uint8(parsed)
+
+ parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err)
+ hasher.keyLen = uint32(parsed)
+ if err != nil {
+ return nil
+ }
+
+ return hasher
+}
diff --git a/modules/auth/password/hash/bcrypt.go b/modules/auth/password/hash/bcrypt.go
new file mode 100644
index 0000000..4607c16
--- /dev/null
+++ b/modules/auth/password/hash/bcrypt.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "golang.org/x/crypto/bcrypt"
+)
+
+func init() {
+ MustRegister("bcrypt", NewBcryptHasher)
+}
+
+// BcryptHasher implements PasswordHasher
+// and uses the bcrypt password hash function.
+type BcryptHasher struct {
+ cost int
+}
+
+// HashWithSaltBytes a provided password and salt
+func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string {
+ if hasher == nil {
+ return ""
+ }
+ hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost)
+ return string(hashedPassword)
+}
+
+func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
+}
+
+// NewBcryptHasher is a factory method to create an BcryptHasher
+// The provided config should be either empty or the string representation of the "<cost>"
+// as an integer
+func NewBcryptHasher(config string) *BcryptHasher {
+ // This matches the original configuration for `bcrypt` prior to storing hash parameters
+ // in the database.
+ // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
+ hasher := &BcryptHasher{
+ cost: 10, // cost=10. i.e. 2^10 rounds of key expansion.
+ }
+
+ if config == "" {
+ return hasher
+ }
+ var err error
+ hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil)
+ if err != nil {
+ return nil
+ }
+
+ return hasher
+}
diff --git a/modules/auth/password/hash/common.go b/modules/auth/password/hash/common.go
new file mode 100644
index 0000000..487c073
--- /dev/null
+++ b/modules/auth/password/hash/common.go
@@ -0,0 +1,28 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "strconv"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) {
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
+ return 0, err
+ }
+ return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
+}
+
+func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) { //nolint:unparam
+ parsed, err := strconv.ParseUint(value, 10, 64)
+ if err != nil {
+ log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
+ return 0, err
+ }
+ return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
+}
diff --git a/modules/auth/password/hash/dummy.go b/modules/auth/password/hash/dummy.go
new file mode 100644
index 0000000..22f2e2f
--- /dev/null
+++ b/modules/auth/password/hash/dummy.go
@@ -0,0 +1,33 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "encoding/hex"
+)
+
+// DummyHasher implements PasswordHasher and is a dummy hasher that simply
+// puts the password in place with its salt
+// This SHOULD NOT be used in production and is provided to make the integration
+// tests faster only
+type DummyHasher struct{}
+
+// HashWithSaltBytes a provided password and salt
+func (hasher *DummyHasher) HashWithSaltBytes(password string, salt []byte) string {
+ if hasher == nil {
+ return ""
+ }
+
+ if len(salt) == 10 {
+ return string(salt) + ":" + password
+ }
+
+ return hex.EncodeToString(salt) + ":" + password
+}
+
+// NewDummyHasher is a factory method to create a DummyHasher
+// Any provided configuration is ignored
+func NewDummyHasher(_ string) *DummyHasher {
+ return &DummyHasher{}
+}
diff --git a/modules/auth/password/hash/dummy_test.go b/modules/auth/password/hash/dummy_test.go
new file mode 100644
index 0000000..35d1249
--- /dev/null
+++ b/modules/auth/password/hash/dummy_test.go
@@ -0,0 +1,26 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDummyHasher(t *testing.T) {
+ dummy := &PasswordHashAlgorithm{
+ PasswordSaltHasher: NewDummyHasher(""),
+ Specification: "dummy",
+ }
+
+ password, salt := "password", "ZogKvWdyEx"
+
+ hash, err := dummy.Hash(password, salt)
+ require.NoError(t, err)
+ assert.Equal(t, hash, salt+":"+password)
+
+ assert.True(t, dummy.VerifyPassword(password, hash, salt))
+}
diff --git a/modules/auth/password/hash/hash.go b/modules/auth/password/hash/hash.go
new file mode 100644
index 0000000..459320e
--- /dev/null
+++ b/modules/auth/password/hash/hash.go
@@ -0,0 +1,189 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "crypto/subtle"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// This package takes care of hashing passwords, verifying passwords, defining
+// available password algorithms, defining recommended password algorithms and
+// choosing the default password algorithm.
+
+// PasswordSaltHasher will hash a provided password with the provided saltBytes
+type PasswordSaltHasher interface {
+ HashWithSaltBytes(password string, saltBytes []byte) string
+}
+
+// PasswordHasher will hash a provided password with the salt
+type PasswordHasher interface {
+ Hash(password, salt string) (string, error)
+}
+
+// PasswordVerifier will ensure that a providedPassword matches the hashPassword when hashed with the salt
+type PasswordVerifier interface {
+ VerifyPassword(providedPassword, hashedPassword, salt string) bool
+}
+
+// PasswordHashAlgorithms are named PasswordSaltHashers with a default verifier and hash function
+type PasswordHashAlgorithm struct {
+ PasswordSaltHasher
+ Specification string // The specification that is used to create the internal PasswordSaltHasher
+}
+
+// Hash the provided password with the salt and return the hash
+func (algorithm *PasswordHashAlgorithm) Hash(password, salt string) (string, error) {
+ var saltBytes []byte
+
+ // There are two formats for the salt value:
+ // * The new format is a (32+)-byte hex-encoded string
+ // * The old format was a 10-byte binary format
+ // We have to tolerate both here.
+ if len(salt) == 10 {
+ saltBytes = []byte(salt)
+ } else {
+ var err error
+ saltBytes, err = hex.DecodeString(salt)
+ if err != nil {
+ return "", err
+ }
+ }
+
+ return algorithm.HashWithSaltBytes(password, saltBytes), nil
+}
+
+// Verify the provided password matches the hashPassword when hashed with the salt
+func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool {
+ // Some PasswordSaltHashers have their own specialised compare function that takes into
+ // account the stored parameters within the hash. e.g. bcrypt
+ if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok {
+ return verifier.VerifyPassword(providedPassword, hashedPassword, salt)
+ }
+
+ // Compute the hash of the password.
+ providedPasswordHash, err := algorithm.Hash(providedPassword, salt)
+ if err != nil {
+ log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err)
+ return false
+ }
+
+ // Compare it against the hashed password in constant-time.
+ return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1
+}
+
+var (
+ lastNonDefaultAlgorithm atomic.Value
+ availableHasherFactories = map[string]func(string) PasswordSaltHasher{}
+)
+
+// MustRegister registers a PasswordSaltHasher with the availableHasherFactories
+// Caution: This is not thread safe.
+func MustRegister[T PasswordSaltHasher](name string, newFn func(config string) T) {
+ if err := Register(name, newFn); err != nil {
+ panic(err)
+ }
+}
+
+// Register registers a PasswordSaltHasher with the availableHasherFactories
+// Caution: This is not thread safe.
+func Register[T PasswordSaltHasher](name string, newFn func(config string) T) error {
+ if _, has := availableHasherFactories[name]; has {
+ return fmt.Errorf("duplicate registration of password salt hasher: %s", name)
+ }
+
+ availableHasherFactories[name] = func(config string) PasswordSaltHasher {
+ n := newFn(config)
+ return n
+ }
+ return nil
+}
+
+// In early versions of gitea the password hash algorithm field of a user could be
+// empty. At that point the default was `pbkdf2` without configuration values
+//
+// Please note this is not the same as the DefaultAlgorithm which is used
+// to determine what an empty PASSWORD_HASH_ALGO setting in the app.ini means.
+// These are not the same even if they have the same apparent value and they mean different things.
+//
+// DO NOT COALESCE THESE VALUES
+const defaultEmptyHashAlgorithmSpecification = "pbkdf2"
+
+// Parse will convert the provided algorithm specification in to a PasswordHashAlgorithm
+// If the provided specification matches the DefaultHashAlgorithm Specification it will be
+// used.
+// In addition the last non-default hasher will be cached to help reduce the load from
+// parsing specifications.
+//
+// NOTE: No de-aliasing is done in this function, thus any specification which does not
+// contain a configuration will use the default values for that hasher. These are not
+// necessarily the same values as those obtained by dealiasing. This allows for
+// seamless backwards compatibility with the original configuration.
+//
+// To further labour this point, running `Parse("pbkdf2")` does not obtain the
+// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to.
+// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm
+// Users will be migrated automatically as they log-in to have the complete specification stored
+// in their `password_hash_algo` fields by other code.
+func Parse(algorithmSpec string) *PasswordHashAlgorithm {
+ if algorithmSpec == "" {
+ algorithmSpec = defaultEmptyHashAlgorithmSpecification
+ }
+
+ if DefaultHashAlgorithm != nil && algorithmSpec == DefaultHashAlgorithm.Specification {
+ return DefaultHashAlgorithm
+ }
+
+ ptr := lastNonDefaultAlgorithm.Load()
+ if ptr != nil {
+ hashAlgorithm, ok := ptr.(*PasswordHashAlgorithm)
+ if ok && hashAlgorithm.Specification == algorithmSpec {
+ return hashAlgorithm
+ }
+ }
+
+ // Now convert the provided specification in to a hasherType +/- some configuration parameters
+ vals := strings.SplitN(algorithmSpec, "$", 2)
+ var hasherType string
+ var config string
+
+ if len(vals) == 0 {
+ // This should not happen as algorithmSpec should not be empty
+ // due to it being assigned to defaultEmptyHashAlgorithmSpecification above
+ // but we should be absolutely cautious here
+ return nil
+ }
+
+ hasherType = vals[0]
+ if len(vals) > 1 {
+ config = vals[1]
+ }
+
+ newFn, has := availableHasherFactories[hasherType]
+ if !has {
+ // unknown hasher type
+ return nil
+ }
+
+ ph := newFn(config)
+ if ph == nil {
+ // The provided configuration is likely invalid - it will have been logged already
+ // but we cannot hash safely
+ return nil
+ }
+
+ hashAlgorithm := &PasswordHashAlgorithm{
+ PasswordSaltHasher: ph,
+ Specification: algorithmSpec,
+ }
+
+ lastNonDefaultAlgorithm.Store(hashAlgorithm)
+
+ return hashAlgorithm
+}
diff --git a/modules/auth/password/hash/hash_test.go b/modules/auth/password/hash/hash_test.go
new file mode 100644
index 0000000..03d08a8
--- /dev/null
+++ b/modules/auth/password/hash/hash_test.go
@@ -0,0 +1,191 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "encoding/hex"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testSaltHasher string
+
+func (t testSaltHasher) HashWithSaltBytes(password string, salt []byte) string {
+ return password + "$" + string(salt) + "$" + string(t)
+}
+
+func Test_registerHasher(t *testing.T) {
+ MustRegister("Test_registerHasher", func(config string) testSaltHasher {
+ return testSaltHasher(config)
+ })
+
+ assert.Panics(t, func() {
+ MustRegister("Test_registerHasher", func(config string) testSaltHasher {
+ return testSaltHasher(config)
+ })
+ })
+
+ require.Error(t, Register("Test_registerHasher", func(config string) testSaltHasher {
+ return testSaltHasher(config)
+ }))
+
+ assert.Equal(t, "password$salt$",
+ Parse("Test_registerHasher").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
+
+ assert.Equal(t, "password$salt$config",
+ Parse("Test_registerHasher$config").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
+
+ delete(availableHasherFactories, "Test_registerHasher")
+}
+
+func TestParse(t *testing.T) {
+ hashAlgorithmsToTest := []string{}
+ for plainHashAlgorithmNames := range availableHasherFactories {
+ hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
+ }
+ for _, aliased := range aliasAlgorithmNames {
+ if strings.Contains(aliased, "$") {
+ hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
+ }
+ }
+ for _, algorithmName := range hashAlgorithmsToTest {
+ t.Run(algorithmName, func(t *testing.T) {
+ algo := Parse(algorithmName)
+ assert.NotNil(t, algo, "Algorithm %s resulted in an empty algorithm", algorithmName)
+ })
+ }
+}
+
+func TestHashing(t *testing.T) {
+ hashAlgorithmsToTest := []string{}
+ for plainHashAlgorithmNames := range availableHasherFactories {
+ hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
+ }
+ for _, aliased := range aliasAlgorithmNames {
+ if strings.Contains(aliased, "$") {
+ hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
+ }
+ }
+
+ runTests := func(password, salt string, shouldPass bool) {
+ for _, algorithmName := range hashAlgorithmsToTest {
+ t.Run(algorithmName, func(t *testing.T) {
+ output, err := Parse(algorithmName).Hash(password, salt)
+ if shouldPass {
+ require.NoError(t, err)
+ assert.NotEmpty(t, output, "output for %s was empty", algorithmName)
+ } else {
+ require.Error(t, err)
+ }
+
+ assert.Equal(t, Parse(algorithmName).VerifyPassword(password, output, salt), shouldPass)
+ })
+ }
+ }
+
+ // Test with new salt format.
+ runTests(strings.Repeat("a", 16), hex.EncodeToString([]byte{0x01, 0x02, 0x03}), true)
+
+ // Test with legacy salt format.
+ runTests(strings.Repeat("a", 16), strings.Repeat("b", 10), true)
+
+ // Test with invalid salt.
+ runTests(strings.Repeat("a", 16), "a", false)
+}
+
+// vectors were generated using the current codebase.
+var vectors = []struct {
+ algorithms []string
+ password string
+ salt string
+ output string
+ shouldfail bool
+}{
+ {
+ algorithms: []string{"bcrypt", "bcrypt$10"},
+ password: "abcdef",
+ salt: strings.Repeat("a", 10),
+ output: "$2a$10$fjtm8BsQ2crym01/piJroenO3oSVUBhSLKaGdTYJ4tG0ePVCrU0G2",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
+ password: "abcdef",
+ salt: strings.Repeat("a", 10),
+ output: "3b571d0c07c62d42b7bad3dbf18fb0cd67d4d8cd4ad4c6928e1090e5b2a4a84437c6fd2627d897c0e7e65025ca62b67a0002",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"argon2", "argon2$2$65536$8$50"},
+ password: "abcdef",
+ salt: strings.Repeat("a", 10),
+ output: "551f089f570f989975b6f7c6a8ff3cf89bc486dd7bbe87ed4d80ad4362f8ee599ec8dda78dac196301b98456402bcda775dc",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
+ password: "abcdef",
+ salt: strings.Repeat("a", 10),
+ output: "ab48d5471b7e6ed42d10001db88c852ff7303c788e49da5c3c7b63d5adf96360303724b74b679223a3dea8a242d10abb1913",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"bcrypt", "bcrypt$10"},
+ password: "abcdef",
+ salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
+ output: "$2a$10$qhgm32w9ZpqLygugWJsLjey8xRGcaq9iXAfmCeNBXxddgyoaOC3Gq",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
+ password: "abcdef",
+ salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
+ output: "25fe5f66b43fa4eb7b6717905317cd2223cf841092dc8e0a1e8c75720ad4846cb5d9387303e14bc3c69faa3b1c51ef4b7de1",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"argon2", "argon2$2$65536$8$50"},
+ password: "abcdef",
+ salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
+ output: "9c287db63a91d18bb1414b703216da4fc431387c1ae7c8acdb280222f11f0929831055dbfd5126a3b48566692e83ec750d2a",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
+ password: "abcdef",
+ salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
+ output: "45d6cdc843d65cf0eda7b90ab41435762a282f7df013477a1c5b212ba81dbdca2edf1ecc4b5cb05956bb9e0c37ab29315d78",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"pbkdf2$320000$50"},
+ password: "abcdef",
+ salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
+ output: "84e233114499e8721da80e85568e5b7b5900b3e49a30845fcda9d1e1756da4547d70f8740ac2b4a5d82f88cebcd27f21bfe2",
+ shouldfail: false,
+ },
+ {
+ algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
+ password: "abcdef",
+ salt: "",
+ output: "",
+ shouldfail: true,
+ },
+}
+
+// Ensure that the current code will correctly verify against the test vectors.
+func TestVectors(t *testing.T) {
+ for i, vector := range vectors {
+ for _, algorithm := range vector.algorithms {
+ t.Run(strconv.Itoa(i)+": "+algorithm, func(t *testing.T) {
+ pa := Parse(algorithm)
+ assert.Equal(t, !vector.shouldfail, pa.VerifyPassword(vector.password, vector.output, vector.salt))
+ })
+ }
+ }
+}
diff --git a/modules/auth/password/hash/pbkdf2.go b/modules/auth/password/hash/pbkdf2.go
new file mode 100644
index 0000000..27382fe
--- /dev/null
+++ b/modules/auth/password/hash/pbkdf2.go
@@ -0,0 +1,67 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+func init() {
+ MustRegister("pbkdf2", NewPBKDF2Hasher)
+}
+
+// PBKDF2Hasher implements PasswordHasher
+// and uses the PBKDF2 key derivation function.
+type PBKDF2Hasher struct {
+ iter, keyLen int
+}
+
+// HashWithSaltBytes a provided password and salt
+func (hasher *PBKDF2Hasher) HashWithSaltBytes(password string, salt []byte) string {
+ if hasher == nil {
+ return ""
+ }
+ return hex.EncodeToString(pbkdf2.Key([]byte(password), salt, hasher.iter, hasher.keyLen, sha256.New))
+}
+
+// NewPBKDF2Hasher is a factory method to create an PBKDF2Hasher
+// config should be either empty or of the form:
+// "<iter>$<keyLen>", where <x> is the string representation
+// of an integer
+func NewPBKDF2Hasher(config string) *PBKDF2Hasher {
+ // This default configuration uses the following parameters:
+ // iter=10000, keyLen=50.
+ // This matches the original configuration for `pbkdf2` prior to storing parameters
+ // in the database.
+ // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
+ hasher := &PBKDF2Hasher{
+ iter: 10_000,
+ keyLen: 50,
+ }
+
+ if config == "" {
+ return hasher
+ }
+
+ vals := strings.SplitN(config, "$", 2)
+ if len(vals) != 2 {
+ log.Error("invalid pbkdf2 hash spec %s", config)
+ return nil
+ }
+
+ var err error
+ hasher.iter, err = parseIntParam(vals[0], "iter", "pbkdf2", config, nil)
+ hasher.keyLen, err = parseIntParam(vals[1], "keyLen", "pbkdf2", config, err)
+ if err != nil {
+ return nil
+ }
+
+ return hasher
+}
diff --git a/modules/auth/password/hash/scrypt.go b/modules/auth/password/hash/scrypt.go
new file mode 100644
index 0000000..f3d38f7
--- /dev/null
+++ b/modules/auth/password/hash/scrypt.go
@@ -0,0 +1,67 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "encoding/hex"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "golang.org/x/crypto/scrypt"
+)
+
+func init() {
+ MustRegister("scrypt", NewScryptHasher)
+}
+
+// ScryptHasher implements PasswordHasher
+// and uses the scrypt key derivation function.
+type ScryptHasher struct {
+ n, r, p, keyLen int
+}
+
+// HashWithSaltBytes a provided password and salt
+func (hasher *ScryptHasher) HashWithSaltBytes(password string, salt []byte) string {
+ if hasher == nil {
+ return ""
+ }
+ hashedPassword, _ := scrypt.Key([]byte(password), salt, hasher.n, hasher.r, hasher.p, hasher.keyLen)
+ return hex.EncodeToString(hashedPassword)
+}
+
+// NewScryptHasher is a factory method to create an ScryptHasher
+// The provided config should be either empty or of the form:
+// "<n>$<r>$<p>$<keyLen>", where <x> is the string representation
+// of an integer
+func NewScryptHasher(config string) *ScryptHasher {
+ // This matches the original configuration for `scrypt` prior to storing hash parameters
+ // in the database.
+ // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
+ hasher := &ScryptHasher{
+ n: 1 << 16,
+ r: 16,
+ p: 2, // 2 passes through memory - this default config will use 128MiB in total.
+ keyLen: 50,
+ }
+
+ if config == "" {
+ return hasher
+ }
+
+ vals := strings.SplitN(config, "$", 4)
+ if len(vals) != 4 {
+ log.Error("invalid scrypt hash spec %s", config)
+ return nil
+ }
+ var err error
+ hasher.n, err = parseIntParam(vals[0], "n", "scrypt", config, nil)
+ hasher.r, err = parseIntParam(vals[1], "r", "scrypt", config, err)
+ hasher.p, err = parseIntParam(vals[2], "p", "scrypt", config, err)
+ hasher.keyLen, err = parseIntParam(vals[3], "keyLen", "scrypt", config, err)
+ if err != nil {
+ return nil
+ }
+ return hasher
+}
diff --git a/modules/auth/password/hash/setting.go b/modules/auth/password/hash/setting.go
new file mode 100644
index 0000000..05cd36f
--- /dev/null
+++ b/modules/auth/password/hash/setting.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+// DefaultHashAlgorithmName represents the default value of PASSWORD_HASH_ALGO
+// configured in app.ini.
+//
+// It is NOT the same and does NOT map to the defaultEmptyHashAlgorithmSpecification.
+//
+// It will be dealiased as per aliasAlgorithmNames whereas
+// defaultEmptyHashAlgorithmSpecification does not undergo dealiasing.
+const DefaultHashAlgorithmName = "pbkdf2_hi"
+
+var DefaultHashAlgorithm *PasswordHashAlgorithm
+
+// aliasAlgorithNames provides a mapping between the value of PASSWORD_HASH_ALGO
+// configured in the app.ini and the parameters used within the hashers internally.
+//
+// If it is necessary to change the default parameters for any hasher in future you
+// should change these values and not those in argon2.go etc.
+var aliasAlgorithmNames = map[string]string{
+ "argon2": "argon2$2$65536$8$50",
+ "bcrypt": "bcrypt$10",
+ "scrypt": "scrypt$65536$16$2$50",
+ "pbkdf2": "pbkdf2_v2", // pbkdf2 should default to pbkdf2_v2
+ "pbkdf2_v1": "pbkdf2$10000$50",
+ // The latest PBKDF2 password algorithm is used as the default since it doesn't
+ // use a lot of memory and is safer to use on less powerful devices.
+ "pbkdf2_v2": "pbkdf2$50000$50",
+ // The pbkdf2_hi password algorithm is offered as a stronger alternative to the
+ // slightly improved pbkdf2_v2 algorithm
+ "pbkdf2_hi": "pbkdf2$320000$50",
+}
+
+var RecommendedHashAlgorithms = []string{
+ "pbkdf2",
+ "argon2",
+ "bcrypt",
+ "scrypt",
+ "pbkdf2_hi",
+}
+
+// hashAlgorithmToSpec converts an algorithm name or a specification to a full algorithm specification
+func hashAlgorithmToSpec(algorithmName string) string {
+ if algorithmName == "" {
+ algorithmName = DefaultHashAlgorithmName
+ }
+ alias, has := aliasAlgorithmNames[algorithmName]
+ for has {
+ algorithmName = alias
+ alias, has = aliasAlgorithmNames[algorithmName]
+ }
+ return algorithmName
+}
+
+// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and de-alias it to
+// a complete algorithm specification.
+func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) {
+ algoSpec := hashAlgorithmToSpec(algorithmName)
+ // now we get a full specification, e.g. pbkdf2$50000$50 rather than pbdkf2
+ DefaultHashAlgorithm = Parse(algoSpec)
+ return algoSpec, DefaultHashAlgorithm
+}
+
+// ConfigHashAlgorithm will try to find a "recommended algorithm name" defined by RecommendedHashAlgorithms for config
+// This function is not fast and is only used for the installation page
+func ConfigHashAlgorithm(algorithm string) string {
+ algorithm = hashAlgorithmToSpec(algorithm)
+ for _, recommAlgo := range RecommendedHashAlgorithms {
+ if algorithm == hashAlgorithmToSpec(recommAlgo) {
+ return recommAlgo
+ }
+ }
+ return algorithm
+}
diff --git a/modules/auth/password/hash/setting_test.go b/modules/auth/password/hash/setting_test.go
new file mode 100644
index 0000000..548d87c
--- /dev/null
+++ b/modules/auth/password/hash/setting_test.go
@@ -0,0 +1,38 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hash
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCheckSettingPasswordHashAlgorithm(t *testing.T) {
+ t.Run("pbkdf2 is pbkdf2_v2", func(t *testing.T) {
+ pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2")
+ pbkdf2Config, pbkdf2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2")
+
+ assert.Equal(t, pbkdf2v2Config, pbkdf2Config)
+ assert.Equal(t, pbkdf2v2Algo.Specification, pbkdf2Algo.Specification)
+ })
+
+ for a, b := range aliasAlgorithmNames {
+ t.Run(a+"="+b, func(t *testing.T) {
+ aConfig, aAlgo := SetDefaultPasswordHashAlgorithm(a)
+ bConfig, bAlgo := SetDefaultPasswordHashAlgorithm(b)
+
+ assert.Equal(t, bConfig, aConfig)
+ assert.Equal(t, aAlgo.Specification, bAlgo.Specification)
+ })
+ }
+
+ t.Run("pbkdf2_hi is the default when default password hash algorithm is empty", func(t *testing.T) {
+ emptyConfig, emptyAlgo := SetDefaultPasswordHashAlgorithm("")
+ pbkdf2hiConfig, pbkdf2hiAlgo := SetDefaultPasswordHashAlgorithm("pbkdf2_hi")
+
+ assert.Equal(t, pbkdf2hiConfig, emptyConfig)
+ assert.Equal(t, pbkdf2hiAlgo.Specification, emptyAlgo.Specification)
+ })
+}
diff --git a/modules/auth/password/password.go b/modules/auth/password/password.go
new file mode 100644
index 0000000..85f9780
--- /dev/null
+++ b/modules/auth/password/password.go
@@ -0,0 +1,136 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package password
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "errors"
+ "html/template"
+ "math/big"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+)
+
+var (
+ ErrComplexity = errors.New("password not complex enough")
+ ErrMinLength = errors.New("password not long enough")
+)
+
+// complexity contains information about a particular kind of password complexity
+type complexity struct {
+ ValidChars string
+ TrNameOne string
+}
+
+var (
+ matchComplexityOnce sync.Once
+ validChars string
+ requiredList []complexity
+
+ charComplexities = map[string]complexity{
+ "lower": {
+ `abcdefghijklmnopqrstuvwxyz`,
+ "form.password_lowercase_one",
+ },
+ "upper": {
+ `ABCDEFGHIJKLMNOPQRSTUVWXYZ`,
+ "form.password_uppercase_one",
+ },
+ "digit": {
+ `0123456789`,
+ "form.password_digit_one",
+ },
+ "spec": {
+ ` !"#$%&'()*+,-./:;<=>?@[\]^_{|}~` + "`",
+ "form.password_special_one",
+ },
+ }
+)
+
+// NewComplexity for preparation
+func NewComplexity() {
+ matchComplexityOnce.Do(func() {
+ setupComplexity(setting.PasswordComplexity)
+ })
+}
+
+func setupComplexity(values []string) {
+ if len(values) != 1 || values[0] != "off" {
+ for _, val := range values {
+ if complexity, ok := charComplexities[val]; ok {
+ validChars += complexity.ValidChars
+ requiredList = append(requiredList, complexity)
+ }
+ }
+ if len(requiredList) == 0 {
+ // No valid character classes found; use all classes as default
+ for _, complexity := range charComplexities {
+ validChars += complexity.ValidChars
+ requiredList = append(requiredList, complexity)
+ }
+ }
+ }
+ if validChars == "" {
+ // No complexities to check; provide a sensible default for password generation
+ validChars = charComplexities["lower"].ValidChars + charComplexities["upper"].ValidChars + charComplexities["digit"].ValidChars
+ }
+}
+
+// IsComplexEnough return True if password meets complexity settings
+func IsComplexEnough(pwd string) bool {
+ NewComplexity()
+ if len(validChars) > 0 {
+ for _, req := range requiredList {
+ if !strings.ContainsAny(req.ValidChars, pwd) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// Generate a random password
+func Generate(n int) (string, error) {
+ NewComplexity()
+ buffer := make([]byte, n)
+ max := big.NewInt(int64(len(validChars)))
+ for {
+ for j := 0; j < n; j++ {
+ rnd, err := rand.Int(rand.Reader, max)
+ if err != nil {
+ return "", err
+ }
+ buffer[j] = validChars[rnd.Int64()]
+ }
+
+ if err := IsPwned(context.Background(), string(buffer)); err != nil {
+ if errors.Is(err, ErrIsPwned) {
+ continue
+ }
+ return "", err
+ }
+ if IsComplexEnough(string(buffer)) && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
+ return string(buffer), nil
+ }
+ }
+}
+
+// BuildComplexityError builds the error message when password complexity checks fail
+func BuildComplexityError(locale translation.Locale) template.HTML {
+ var buffer bytes.Buffer
+ buffer.WriteString(locale.TrString("form.password_complexity"))
+ buffer.WriteString("<ul>")
+ for _, c := range requiredList {
+ buffer.WriteString("<li>")
+ buffer.WriteString(locale.TrString(c.TrNameOne))
+ buffer.WriteString("</li>")
+ }
+ buffer.WriteString("</ul>")
+ return template.HTML(buffer.String())
+}
diff --git a/modules/auth/password/password_test.go b/modules/auth/password/password_test.go
new file mode 100644
index 0000000..1fe3fb5
--- /dev/null
+++ b/modules/auth/password/password_test.go
@@ -0,0 +1,77 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package password
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestComplexity_IsComplexEnough(t *testing.T) {
+ matchComplexityOnce.Do(func() {})
+
+ testlist := []struct {
+ complexity []string
+ truevalues []string
+ falsevalues []string
+ }{
+ {[]string{"off"}, []string{"1", "-", "a", "A", "ñ", "日本語"}, []string{}},
+ {[]string{"lower"}, []string{"abc", "abc!"}, []string{"ABC", "123", "=!$", ""}},
+ {[]string{"upper"}, []string{"ABC"}, []string{"abc", "123", "=!$", "abc!", ""}},
+ {[]string{"digit"}, []string{"123"}, []string{"abc", "ABC", "=!$", "abc!", ""}},
+ {[]string{"spec"}, []string{"=!$", "abc!"}, []string{"abc", "ABC", "123", ""}},
+ {[]string{"off"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}, nil},
+ {[]string{"lower", "spec"}, []string{"abc!"}, []string{"abc", "ABC", "123", "=!$", "abcABC123", ""}},
+ {[]string{"lower", "upper", "digit"}, []string{"abcABC123"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}},
+ {[]string{""}, []string{"abC=1", "abc!9D"}, []string{"ABC", "123", "=!$", ""}},
+ }
+
+ for _, test := range testlist {
+ testComplextity(test.complexity)
+ for _, val := range test.truevalues {
+ assert.True(t, IsComplexEnough(val))
+ }
+ for _, val := range test.falsevalues {
+ assert.False(t, IsComplexEnough(val))
+ }
+ }
+
+ // Remove settings for other tests
+ testComplextity([]string{"off"})
+}
+
+func TestComplexity_Generate(t *testing.T) {
+ matchComplexityOnce.Do(func() {})
+
+ const maxCount = 50
+ const pwdLen = 50
+
+ test := func(t *testing.T, modes []string) {
+ testComplextity(modes)
+ for i := 0; i < maxCount; i++ {
+ pwd, err := Generate(pwdLen)
+ require.NoError(t, err)
+ assert.Len(t, pwd, pwdLen)
+ assert.True(t, IsComplexEnough(pwd), "Failed complexities with modes %+v for generated: %s", modes, pwd)
+ }
+ }
+
+ test(t, []string{"lower"})
+ test(t, []string{"upper"})
+ test(t, []string{"lower", "upper", "spec"})
+ test(t, []string{"off"})
+ test(t, []string{""})
+
+ // Remove settings for other tests
+ testComplextity([]string{"off"})
+}
+
+func testComplextity(values []string) {
+ // Cleanup previous values
+ validChars = ""
+ requiredList = make([]complexity, 0, len(values))
+ setupComplexity(values)
+}
diff --git a/modules/auth/password/pwn.go b/modules/auth/password/pwn.go
new file mode 100644
index 0000000..e00205e
--- /dev/null
+++ b/modules/auth/password/pwn.go
@@ -0,0 +1,52 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package password
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/auth/password/pwn"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+var ErrIsPwned = errors.New("password has been pwned")
+
+type ErrIsPwnedRequest struct {
+ err error
+}
+
+func IsErrIsPwnedRequest(err error) bool {
+ _, ok := err.(ErrIsPwnedRequest)
+ return ok
+}
+
+func (err ErrIsPwnedRequest) Error() string {
+ return fmt.Sprintf("using Have-I-Been-Pwned service failed: %v", err.err)
+}
+
+func (err ErrIsPwnedRequest) Unwrap() error {
+ return err.err
+}
+
+// IsPwned checks whether a password has been pwned
+// If a password has not been pwned, no error is returned.
+func IsPwned(ctx context.Context, password string) error {
+ if !setting.PasswordCheckPwn {
+ return nil
+ }
+
+ client := pwn.New(pwn.WithContext(ctx))
+ count, err := client.CheckPassword(password, true)
+ if err != nil {
+ return ErrIsPwnedRequest{err}
+ }
+
+ if count > 0 {
+ return ErrIsPwned
+ }
+
+ return nil
+}
diff --git a/modules/auth/password/pwn/pwn.go b/modules/auth/password/pwn/pwn.go
new file mode 100644
index 0000000..f77ce9f
--- /dev/null
+++ b/modules/auth/password/pwn/pwn.go
@@ -0,0 +1,118 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pwn
+
+import (
+ "context"
+ "crypto/sha1"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+const passwordURL = "https://api.pwnedpasswords.com/range/"
+
+// ErrEmptyPassword is an empty password error
+var ErrEmptyPassword = errors.New("password cannot be empty")
+
+// Client is a HaveIBeenPwned client
+type Client struct {
+ ctx context.Context
+ http *http.Client
+}
+
+// New returns a new HaveIBeenPwned Client
+func New(options ...ClientOption) *Client {
+ client := &Client{
+ ctx: context.Background(),
+ http: http.DefaultClient,
+ }
+
+ for _, opt := range options {
+ opt(client)
+ }
+
+ return client
+}
+
+// ClientOption is a way to modify a new Client
+type ClientOption func(*Client)
+
+// WithHTTP will set the http.Client of a Client
+func WithHTTP(httpClient *http.Client) func(pwnClient *Client) {
+ return func(pwnClient *Client) {
+ pwnClient.http = httpClient
+ }
+}
+
+// WithContext will set the context.Context of a Client
+func WithContext(ctx context.Context) func(pwnClient *Client) {
+ return func(pwnClient *Client) {
+ pwnClient.ctx = ctx
+ }
+}
+
+func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("User-Agent", "Gitea "+setting.AppVer)
+ return req, nil
+}
+
+// CheckPassword returns the number of times a password has been compromised
+// Adding padding will make requests more secure, however is also slower
+// because artificial responses will be added to the response
+// For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
+func (c *Client) CheckPassword(pw string, padding bool) (int, error) {
+ if pw == "" {
+ return -1, ErrEmptyPassword
+ }
+
+ sha := sha1.New()
+ sha.Write([]byte(pw))
+ enc := hex.EncodeToString(sha.Sum(nil))
+ prefix, suffix := enc[:5], enc[5:]
+
+ req, err := newRequest(c.ctx, http.MethodGet, fmt.Sprintf("%s%s", passwordURL, prefix), nil)
+ if err != nil {
+ return -1, nil
+ }
+ if padding {
+ req.Header.Add("Add-Padding", "true")
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return -1, err
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return -1, err
+ }
+ defer resp.Body.Close()
+
+ for _, pair := range strings.Split(string(body), "\n") {
+ parts := strings.Split(pair, ":")
+ if len(parts) != 2 {
+ continue
+ }
+ if strings.EqualFold(suffix, parts[0]) {
+ count, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
+ if err != nil {
+ return -1, err
+ }
+ return int(count), nil
+ }
+ }
+ return 0, nil
+}
diff --git a/modules/auth/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go
new file mode 100644
index 0000000..e510815
--- /dev/null
+++ b/modules/auth/password/pwn/pwn_test.go
@@ -0,0 +1,51 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pwn
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/h2non/gock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var client = New(WithHTTP(&http.Client{
+ Timeout: time.Second * 2,
+}))
+
+func TestPassword(t *testing.T) {
+ defer gock.Off()
+
+ count, err := client.CheckPassword("", false)
+ require.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword")
+ assert.Equal(t, -1, count)
+
+ gock.New("https://api.pwnedpasswords.com").Get("/range/5c1d8").Times(1).Reply(200).BodyString("EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2")
+ count, err = client.CheckPassword("pwned", false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count)
+
+ gock.New("https://api.pwnedpasswords.com").Get("/range/ba189").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4")
+ count, err = client.CheckPassword("notpwned", false)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+
+ gock.New("https://api.pwnedpasswords.com").Get("/range/a1733").Times(1).Reply(200).BodyString("C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0")
+ count, err = client.CheckPassword("paddedpwned", true)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count)
+
+ gock.New("https://api.pwnedpasswords.com").Get("/range/5617b").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0")
+ count, err = client.CheckPassword("paddednotpwned", true)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+
+ gock.New("https://api.pwnedpasswords.com").Get("/range/79082").Times(1).Reply(200).BodyString("FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0")
+ count, err = client.CheckPassword("paddednotpwnedzero", true)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+}
diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go
new file mode 100644
index 0000000..189d197
--- /dev/null
+++ b/modules/auth/webauthn/webauthn.go
@@ -0,0 +1,77 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webauthn
+
+import (
+ "encoding/binary"
+ "encoding/gob"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// WebAuthn represents the global WebAuthn instance
+var WebAuthn *webauthn.WebAuthn
+
+// Init initializes the WebAuthn instance from the config.
+func Init() {
+ gob.Register(&webauthn.SessionData{})
+
+ appURL, _ := protocol.FullyQualifiedOrigin(setting.AppURL)
+
+ WebAuthn = &webauthn.WebAuthn{
+ Config: &webauthn.Config{
+ RPDisplayName: setting.AppName,
+ RPID: setting.Domain,
+ RPOrigins: []string{appURL},
+ AuthenticatorSelection: protocol.AuthenticatorSelection{
+ UserVerification: "discouraged",
+ },
+ AttestationPreference: protocol.PreferDirectAttestation,
+ },
+ }
+}
+
+// User represents an implementation of webauthn.User based on User model
+type User user_model.User
+
+// WebAuthnID implements the webauthn.User interface
+func (u *User) WebAuthnID() []byte {
+ id := make([]byte, 8)
+ binary.PutVarint(id, u.ID)
+ return id
+}
+
+// WebAuthnName implements the webauthn.User interface
+func (u *User) WebAuthnName() string {
+ if u.LoginName == "" {
+ return u.Name
+ }
+ return u.LoginName
+}
+
+// WebAuthnDisplayName implements the webauthn.User interface
+func (u *User) WebAuthnDisplayName() string {
+ return (*user_model.User)(u).DisplayName()
+}
+
+// WebAuthnIcon implements the webauthn.User interface
+func (u *User) WebAuthnIcon() string {
+ return (*user_model.User)(u).AvatarLink(db.DefaultContext)
+}
+
+// WebAuthnCredentials implementns the webauthn.User interface
+func (u *User) WebAuthnCredentials() []webauthn.Credential {
+ dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID)
+ if err != nil {
+ return nil
+ }
+
+ return dbCreds.ToCredentials()
+}
diff --git a/modules/auth/webauthn/webauthn_test.go b/modules/auth/webauthn/webauthn_test.go
new file mode 100644
index 0000000..15a8d71
--- /dev/null
+++ b/modules/auth/webauthn/webauthn_test.go
@@ -0,0 +1,25 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webauthn
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestInit(t *testing.T) {
+ setting.Domain = "domain"
+ setting.AppName = "AppName"
+ setting.AppURL = "https://domain/"
+ rpOrigin := []string{"https://domain"}
+
+ Init()
+
+ assert.Equal(t, setting.Domain, WebAuthn.Config.RPID)
+ assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
+ assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
+}
diff --git a/modules/avatar/avatar.go b/modules/avatar/avatar.go
new file mode 100644
index 0000000..106215e
--- /dev/null
+++ b/modules/avatar/avatar.go
@@ -0,0 +1,139 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package avatar
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+
+ _ "image/gif" // for processing gif images
+ _ "image/jpeg" // for processing jpeg images
+
+ "code.gitea.io/gitea/modules/avatar/identicon"
+ "code.gitea.io/gitea/modules/setting"
+
+ "golang.org/x/image/draw"
+
+ _ "golang.org/x/image/webp" // for processing webp images
+)
+
+// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is
+// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the
+// usual size of avatar image saved on server, unless the original file is smaller
+// than the size after resizing.
+const DefaultAvatarSize = 256
+
+// RandomImageSize generates and returns a random avatar image unique to input data
+// in custom size (height and width).
+func RandomImageSize(size int, data []byte) (image.Image, error) {
+ // we use white as background, and use dark colors to draw blocks
+ imgMaker, err := identicon.New(size, color.White, identicon.DarkColors...)
+ if err != nil {
+ return nil, fmt.Errorf("identicon.New: %w", err)
+ }
+ return imgMaker.Make(data), nil
+}
+
+// RandomImage generates and returns a random avatar image unique to input data
+// in default size (height and width).
+func RandomImage(data []byte) (image.Image, error) {
+ return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data)
+}
+
+// processAvatarImage process the avatar image data, crop and resize it if necessary.
+// the returned data could be the original image if no processing is needed.
+func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) {
+ imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("image.DecodeConfig: %w", err)
+ }
+
+ // for safety, only accept known types explicitly
+ if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" {
+ return nil, errors.New("unsupported avatar image type")
+ }
+
+ // do not process image which is too large, it would consume too much memory
+ if imgCfg.Width > setting.Avatar.MaxWidth {
+ return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
+ }
+ if imgCfg.Height > setting.Avatar.MaxHeight {
+ return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
+ }
+
+ // If the origin is small enough, just use it, then APNG could be supported,
+ // otherwise, if the image is processed later, APNG loses animation.
+ // And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails.
+ // So for animated webp, if the uploaded file is smaller than maxOriginSize, it will be used, if it's larger, there will be an error.
+ if len(data) < int(maxOriginSize) {
+ return data, nil
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("image.Decode: %w", err)
+ }
+
+ // try to crop and resize the origin image if necessary
+ img = cropSquare(img)
+
+ targetSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
+ img = scale(img, targetSize, targetSize, draw.BiLinear)
+
+ // try to encode the cropped/resized image to png
+ bs := bytes.Buffer{}
+ if err = png.Encode(&bs, img); err != nil {
+ return nil, err
+ }
+ resized := bs.Bytes()
+
+ // usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller
+ if len(data) <= len(resized) {
+ return data, nil
+ }
+
+ return resized, nil
+}
+
+// ProcessAvatarImage process the avatar image data, crop and resize it if necessary.
+// the returned data could be the original image if no processing is needed.
+func ProcessAvatarImage(data []byte) ([]byte, error) {
+ return processAvatarImage(data, setting.Avatar.MaxOriginSize)
+}
+
+// scale resizes the image to width x height using the given scaler.
+func scale(src image.Image, width, height int, scale draw.Scaler) image.Image {
+ rect := image.Rect(0, 0, width, height)
+ dst := image.NewRGBA(rect)
+ scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
+ return dst
+}
+
+// cropSquare crops the largest square image from the center of the image.
+// If the image is already square, it is returned unchanged.
+func cropSquare(src image.Image) image.Image {
+ bounds := src.Bounds()
+ if bounds.Dx() == bounds.Dy() {
+ return src
+ }
+
+ var rect image.Rectangle
+ if bounds.Dx() > bounds.Dy() {
+ // width > height
+ size := bounds.Dy()
+ rect = image.Rect((bounds.Dx()-size)/2, 0, (bounds.Dx()+size)/2, size)
+ } else {
+ // width < height
+ size := bounds.Dx()
+ rect = image.Rect(0, (bounds.Dy()-size)/2, size, (bounds.Dy()+size)/2)
+ }
+
+ dst := image.NewRGBA(rect)
+ draw.Draw(dst, rect, src, rect.Min, draw.Src)
+ return dst
+}
diff --git a/modules/avatar/avatar_test.go b/modules/avatar/avatar_test.go
new file mode 100644
index 0000000..824a38e
--- /dev/null
+++ b/modules/avatar/avatar_test.go
@@ -0,0 +1,137 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package avatar
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "os"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_RandomImageSize(t *testing.T) {
+ _, err := RandomImageSize(0, []byte("gitea@local"))
+ require.Error(t, err)
+
+ _, err = RandomImageSize(64, []byte("gitea@local"))
+ require.NoError(t, err)
+}
+
+func Test_RandomImage(t *testing.T) {
+ _, err := RandomImage([]byte("gitea@local"))
+ require.NoError(t, err)
+}
+
+func Test_ProcessAvatarPNG(t *testing.T) {
+ setting.Avatar.MaxWidth = 4096
+ setting.Avatar.MaxHeight = 4096
+
+ data, err := os.ReadFile("testdata/avatar.png")
+ require.NoError(t, err)
+
+ _, err = processAvatarImage(data, 262144)
+ require.NoError(t, err)
+}
+
+func Test_ProcessAvatarJPEG(t *testing.T) {
+ setting.Avatar.MaxWidth = 4096
+ setting.Avatar.MaxHeight = 4096
+
+ data, err := os.ReadFile("testdata/avatar.jpeg")
+ require.NoError(t, err)
+
+ _, err = processAvatarImage(data, 262144)
+ require.NoError(t, err)
+}
+
+func Test_ProcessAvatarInvalidData(t *testing.T) {
+ setting.Avatar.MaxWidth = 5
+ setting.Avatar.MaxHeight = 5
+
+ _, err := processAvatarImage([]byte{}, 12800)
+ assert.EqualError(t, err, "image.DecodeConfig: image: unknown format")
+}
+
+func Test_ProcessAvatarInvalidImageSize(t *testing.T) {
+ setting.Avatar.MaxWidth = 5
+ setting.Avatar.MaxHeight = 5
+
+ data, err := os.ReadFile("testdata/avatar.png")
+ require.NoError(t, err)
+
+ _, err = processAvatarImage(data, 12800)
+ assert.EqualError(t, err, "image width is too large: 10 > 5")
+}
+
+func Test_ProcessAvatarImage(t *testing.T) {
+ setting.Avatar.MaxWidth = 4096
+ setting.Avatar.MaxHeight = 4096
+ scaledSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
+
+ newImgData := func(size int, optHeight ...int) []byte {
+ width := size
+ height := size
+ if len(optHeight) == 1 {
+ height = optHeight[0]
+ }
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+ bs := bytes.Buffer{}
+ err := png.Encode(&bs, img)
+ require.NoError(t, err)
+ return bs.Bytes()
+ }
+
+ // if origin image canvas is too large, crop and resize it
+ origin := newImgData(500, 600)
+ result, err := processAvatarImage(origin, 0)
+ require.NoError(t, err)
+ assert.NotEqual(t, origin, result)
+ decoded, err := png.Decode(bytes.NewReader(result))
+ require.NoError(t, err)
+ assert.EqualValues(t, scaledSize, decoded.Bounds().Max.X)
+ assert.EqualValues(t, scaledSize, decoded.Bounds().Max.Y)
+
+ // if origin image is smaller than the default size, use the origin image
+ origin = newImgData(1)
+ result, err = processAvatarImage(origin, 0)
+ require.NoError(t, err)
+ assert.Equal(t, origin, result)
+
+ // use the origin image if the origin is smaller
+ origin = newImgData(scaledSize + 100)
+ result, err = processAvatarImage(origin, 0)
+ require.NoError(t, err)
+ assert.Less(t, len(result), len(origin))
+
+ // still use the origin image if the origin doesn't exceed the max-origin-size
+ origin = newImgData(scaledSize + 100)
+ result, err = processAvatarImage(origin, 262144)
+ require.NoError(t, err)
+ assert.Equal(t, origin, result)
+
+ // allow to use known image format (eg: webp) if it is small enough
+ origin, err = os.ReadFile("testdata/animated.webp")
+ require.NoError(t, err)
+ result, err = processAvatarImage(origin, 262144)
+ require.NoError(t, err)
+ assert.Equal(t, origin, result)
+
+ // do not support unknown image formats, eg: SVG may contain embedded JS
+ origin = []byte("<svg></svg>")
+ _, err = processAvatarImage(origin, 262144)
+ require.ErrorContains(t, err, "image: unknown format")
+
+ // make sure the canvas size limit works
+ setting.Avatar.MaxWidth = 5
+ setting.Avatar.MaxHeight = 5
+ origin = newImgData(10)
+ _, err = processAvatarImage(origin, 262144)
+ require.ErrorContains(t, err, "image width is too large: 10 > 5")
+}
diff --git a/modules/avatar/hash.go b/modules/avatar/hash.go
new file mode 100644
index 0000000..50db9c1
--- /dev/null
+++ b/modules/avatar/hash.go
@@ -0,0 +1,28 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package avatar
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strconv"
+)
+
+// HashAvatar will generate a unique string, which ensures that when there's a
+// different unique ID while the data is the same, it will generate a different
+// output. It will generate the output according to:
+// HEX(HASH(uniqueID || - || data))
+// The hash being used is SHA256.
+// The sole purpose of the unique ID is to generate a distinct hash Such that
+// two unique IDs with the same data will have a different hash output.
+// The "-" byte is important to ensure that data cannot be modified such that
+// the first byte is a number, which could lead to a "collision" with the hash
+// of another unique ID.
+func HashAvatar(uniqueID int64, data []byte) string {
+ h := sha256.New()
+ h.Write([]byte(strconv.FormatInt(uniqueID, 10)))
+ h.Write([]byte{'-'})
+ h.Write(data)
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/modules/avatar/hash_test.go b/modules/avatar/hash_test.go
new file mode 100644
index 0000000..1b8249c
--- /dev/null
+++ b/modules/avatar/hash_test.go
@@ -0,0 +1,26 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package avatar_test
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "testing"
+
+ "code.gitea.io/gitea/modules/avatar"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_HashAvatar(t *testing.T) {
+ myImage := image.NewRGBA(image.Rect(0, 0, 32, 32))
+ var buff bytes.Buffer
+ png.Encode(&buff, myImage)
+
+ assert.EqualValues(t, "9ddb5bac41d57e72aa876321d0c09d71090c05f94bc625303801be2f3240d2cb", avatar.HashAvatar(1, buff.Bytes()))
+ assert.EqualValues(t, "9a5d44e5d637b9582a976676e8f3de1dccd877c2fe3e66ca3fab1629f2f47609", avatar.HashAvatar(8, buff.Bytes()))
+ assert.EqualValues(t, "ed7399158672088770de6f5211ce15528ebd675e92fc4fc060c025f4b2794ccb", avatar.HashAvatar(1024, buff.Bytes()))
+ assert.EqualValues(t, "161178642c7d59eb25a61dddced5e6b66eae1c70880d5f148b1b497b767e72d9", avatar.HashAvatar(1024, []byte{}))
+}
diff --git a/modules/avatar/identicon/block.go b/modules/avatar/identicon/block.go
new file mode 100644
index 0000000..cb1803a
--- /dev/null
+++ b/modules/avatar/identicon/block.go
@@ -0,0 +1,717 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Copied and modified from https://github.com/issue9/identicon/ (MIT License)
+
+package identicon
+
+import "image"
+
+var (
+ // the blocks can appear in center, these blocks can be more beautiful
+ centerBlocks = []blockFunc{b0, b1, b2, b3, b19, b26, b27}
+
+ // all blocks
+ blocks = []blockFunc{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22, b23, b24, b25, b26, b27}
+)
+
+type blockFunc func(img *image.Paletted, x, y, size, angle int)
+
+// draw a polygon by points, and the polygon is rotated by angle.
+func drawBlock(img *image.Paletted, x, y, size, angle int, points []int) {
+ if angle != 0 {
+ m := size / 2
+ rotate(points, m, m, angle)
+ }
+
+ for i := 0; i < size; i++ {
+ for j := 0; j < size; j++ {
+ if pointInPolygon(i, j, points) {
+ img.SetColorIndex(x+i, y+j, 1)
+ }
+ }
+ }
+}
+
+// blank
+//
+// --------
+// | |
+// | |
+// | |
+// --------
+func b0(img *image.Paletted, x, y, size, angle int) {}
+
+// full-filled
+//
+// --------
+// |######|
+// |######|
+// |######|
+// --------
+func b1(img *image.Paletted, x, y, size, angle int) {
+ for i := x; i < x+size; i++ {
+ for j := y; j < y+size; j++ {
+ img.SetColorIndex(i, j, 1)
+ }
+ }
+}
+
+// a small block
+//
+// ----------
+// | |
+// | #### |
+// | #### |
+// | |
+// ----------
+func b2(img *image.Paletted, x, y, size, angle int) {
+ l := size / 4
+ x += l
+ y += l
+
+ for i := x; i < x+2*l; i++ {
+ for j := y; j < y+2*l; j++ {
+ img.SetColorIndex(i, j, 1)
+ }
+ }
+}
+
+// diamond
+//
+// ---------
+// | # |
+// | ### |
+// | ##### |
+// |#######|
+// | ##### |
+// | ### |
+// | # |
+// ---------
+func b3(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, 0, []int{
+ m, 0,
+ size, m,
+ m, size,
+ 0, m,
+ m, 0,
+ })
+}
+
+// b4
+//
+// -------
+// |#####|
+// |#### |
+// |### |
+// |## |
+// |# |
+// |------
+func b4(img *image.Paletted, x, y, size, angle int) {
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ size, 0,
+ 0, size,
+ 0, 0,
+ })
+}
+
+// b5
+//
+// ---------
+// | # |
+// | ### |
+// | ##### |
+// |#######|
+func b5(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ size, size,
+ 0, size,
+ m, 0,
+ })
+}
+
+// b6
+//
+// --------
+// |### |
+// |### |
+// |### |
+// --------
+func b6(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ m, size,
+ 0, size,
+ 0, 0,
+ })
+}
+
+// b7 italic cone
+//
+// ---------
+// | # |
+// | ## |
+// | #####|
+// | ####|
+// |--------
+func b7(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ size, m,
+ size, size,
+ m, size,
+ 0, 0,
+ })
+}
+
+// b8 three small triangles
+//
+// -----------
+// | # |
+// | ### |
+// | ##### |
+// | # # |
+// | ### ### |
+// |#########|
+// -----------
+func b8(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ mm := m / 2
+
+ // top
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ 3 * mm, m,
+ mm, m,
+ m, 0,
+ })
+
+ // bottom left
+ drawBlock(img, x, y, size, angle, []int{
+ mm, m,
+ m, size,
+ 0, size,
+ mm, m,
+ })
+
+ // bottom right
+ drawBlock(img, x, y, size, angle, []int{
+ 3 * mm, m,
+ size, size,
+ m, size,
+ 3 * mm, m,
+ })
+}
+
+// b9 italic triangle
+//
+// ---------
+// |# |
+// | #### |
+// | #####|
+// | #### |
+// | # |
+// ---------
+func b9(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ size, m,
+ m, size,
+ 0, 0,
+ })
+}
+
+// b10
+//
+// ----------
+// | ####|
+// | ### |
+// | ## |
+// | # |
+// |#### |
+// |### |
+// |## |
+// |# |
+// ----------
+func b10(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ size, 0,
+ m, m,
+ m, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, m,
+ m, m,
+ 0, size,
+ 0, m,
+ })
+}
+
+// b11
+//
+// ----------
+// |#### |
+// |#### |
+// |#### |
+// | |
+// | |
+// ----------
+func b11(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ m, m,
+ 0, m,
+ 0, 0,
+ })
+}
+
+// b12
+//
+// -----------
+// | |
+// | |
+// |#########|
+// | ##### |
+// | # |
+// -----------
+func b12(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, m,
+ size, m,
+ m, size,
+ 0, m,
+ })
+}
+
+// b13
+//
+// -----------
+// | |
+// | |
+// | # |
+// | ##### |
+// |#########|
+// -----------
+func b13(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ m, m,
+ size, size,
+ 0, size,
+ m, m,
+ })
+}
+
+// b14
+//
+// ---------
+// | # |
+// | ### |
+// |#### |
+// | |
+// | |
+// ---------
+func b14(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ m, m,
+ 0, m,
+ m, 0,
+ })
+}
+
+// b15
+//
+// ----------
+// |##### |
+// |### |
+// |# |
+// | |
+// | |
+// ----------
+func b15(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ 0, m,
+ 0, 0,
+ })
+}
+
+// b16
+//
+// ---------
+// | # |
+// | ##### |
+// |#######|
+// | # |
+// | ##### |
+// |#######|
+// ---------
+func b16(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ size, m,
+ 0, m,
+ m, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ m, m,
+ size, size,
+ 0, size,
+ m, m,
+ })
+}
+
+// b17
+//
+// ----------
+// |##### |
+// |### |
+// |# |
+// | ##|
+// | ##|
+// ----------
+func b17(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ 0, m,
+ 0, 0,
+ })
+
+ quarter := size / 4
+ drawBlock(img, x, y, size, angle, []int{
+ size - quarter, size - quarter,
+ size, size - quarter,
+ size, size,
+ size - quarter, size,
+ size - quarter, size - quarter,
+ })
+}
+
+// b18
+//
+// ----------
+// |##### |
+// |#### |
+// |### |
+// |## |
+// |# |
+// ----------
+func b18(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ 0, size,
+ 0, 0,
+ })
+}
+
+// b19
+//
+// ----------
+// |########|
+// |### ###|
+// |# #|
+// |### ###|
+// |########|
+// ----------
+func b19(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, 0,
+ 0, m,
+ 0, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ size, 0,
+ size, m,
+ m, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ size, m,
+ size, size,
+ m, size,
+ size, m,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, m,
+ m, size,
+ 0, size,
+ 0, m,
+ })
+}
+
+// b20
+//
+// ----------
+// | ## |
+// |### |
+// |## |
+// |## |
+// |# |
+// ----------
+func b20(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ 0, size,
+ 0, m,
+ q, 0,
+ })
+}
+
+// b21
+//
+// ----------
+// | #### |
+// |## #####|
+// |## ##|
+// |## |
+// |# |
+// ----------
+func b21(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ 0, size,
+ 0, m,
+ q, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ size, q,
+ size, m,
+ q, 0,
+ })
+}
+
+// b22
+//
+// ----------
+// | #### |
+// |## ### |
+// |## ##|
+// |## ##|
+// |# #|
+// ----------
+func b22(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ 0, size,
+ 0, m,
+ q, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ size, q,
+ size, size,
+ q, 0,
+ })
+}
+
+// b23
+//
+// ----------
+// | #######|
+// |### #|
+// |## |
+// |## |
+// |# |
+// ----------
+func b23(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ 0, size,
+ 0, m,
+ q, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ size, 0,
+ size, q,
+ q, 0,
+ })
+}
+
+// b24
+//
+// ----------
+// | ## ###|
+// |### ###|
+// |## ## |
+// |## ## |
+// |# # |
+// ----------
+func b24(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ q, 0,
+ 0, size,
+ 0, m,
+ q, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ m, 0,
+ size, 0,
+ m, size,
+ m, 0,
+ })
+}
+
+// b25
+//
+// ----------
+// |# #|
+// |## ###|
+// |## ## |
+// |###### |
+// |#### |
+// ----------
+func b25(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ 0, size,
+ q, size,
+ 0, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, m,
+ size, 0,
+ q, size,
+ 0, m,
+ })
+}
+
+// b26
+//
+// ----------
+// |# #|
+// |### ###|
+// | #### |
+// |### ###|
+// |# #|
+// ----------
+func b26(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ m, q,
+ q, m,
+ 0, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ size, 0,
+ m + q, m,
+ m, q,
+ size, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ size, size,
+ m, m + q,
+ q + m, m,
+ size, size,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, size,
+ q, m,
+ m, q + m,
+ 0, size,
+ })
+}
+
+// b27
+//
+// ----------
+// |########|
+// |## ###|
+// |# #|
+// |### ##|
+// |########|
+// ----------
+func b27(img *image.Paletted, x, y, size, angle int) {
+ m := size / 2
+ q := size / 4
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, 0,
+ size, 0,
+ 0, q,
+ 0, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ q + m, 0,
+ size, 0,
+ size, size,
+ q + m, 0,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ size, q + m,
+ size, size,
+ 0, size,
+ size, q + m,
+ })
+
+ drawBlock(img, x, y, size, angle, []int{
+ 0, size,
+ 0, 0,
+ q, size,
+ 0, size,
+ })
+}
diff --git a/modules/avatar/identicon/colors.go b/modules/avatar/identicon/colors.go
new file mode 100644
index 0000000..09a98bd
--- /dev/null
+++ b/modules/avatar/identicon/colors.go
@@ -0,0 +1,134 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package identicon
+
+import "image/color"
+
+// DarkColors are dark colors for avatar blocks, they come from image/color/palette.WebSafe, and light colors (0xff) are removed
+var DarkColors = []color.Color{
+ color.RGBA{0x00, 0x00, 0x33, 0xff},
+ color.RGBA{0x00, 0x00, 0x66, 0xff},
+ color.RGBA{0x00, 0x00, 0x99, 0xff},
+ color.RGBA{0x00, 0x00, 0xcc, 0xff},
+ color.RGBA{0x00, 0x33, 0x00, 0xff},
+ color.RGBA{0x00, 0x33, 0x33, 0xff},
+ color.RGBA{0x00, 0x33, 0x66, 0xff},
+ color.RGBA{0x00, 0x33, 0x99, 0xff},
+ color.RGBA{0x00, 0x33, 0xcc, 0xff},
+ color.RGBA{0x00, 0x66, 0x00, 0xff},
+ color.RGBA{0x00, 0x66, 0x33, 0xff},
+ color.RGBA{0x00, 0x66, 0x66, 0xff},
+ color.RGBA{0x00, 0x66, 0x99, 0xff},
+ color.RGBA{0x00, 0x66, 0xcc, 0xff},
+ color.RGBA{0x00, 0x99, 0x00, 0xff},
+ color.RGBA{0x00, 0x99, 0x33, 0xff},
+ color.RGBA{0x00, 0x99, 0x66, 0xff},
+ color.RGBA{0x00, 0x99, 0x99, 0xff},
+ color.RGBA{0x00, 0x99, 0xcc, 0xff},
+ color.RGBA{0x00, 0xcc, 0x00, 0xff},
+ color.RGBA{0x00, 0xcc, 0x33, 0xff},
+ color.RGBA{0x00, 0xcc, 0x66, 0xff},
+ color.RGBA{0x00, 0xcc, 0x99, 0xff},
+ color.RGBA{0x00, 0xcc, 0xcc, 0xff},
+ color.RGBA{0x33, 0x00, 0x00, 0xff},
+ color.RGBA{0x33, 0x00, 0x33, 0xff},
+ color.RGBA{0x33, 0x00, 0x66, 0xff},
+ color.RGBA{0x33, 0x00, 0x99, 0xff},
+ color.RGBA{0x33, 0x00, 0xcc, 0xff},
+ color.RGBA{0x33, 0x33, 0x00, 0xff},
+ color.RGBA{0x33, 0x33, 0x33, 0xff},
+ color.RGBA{0x33, 0x33, 0x66, 0xff},
+ color.RGBA{0x33, 0x33, 0x99, 0xff},
+ color.RGBA{0x33, 0x33, 0xcc, 0xff},
+ color.RGBA{0x33, 0x66, 0x00, 0xff},
+ color.RGBA{0x33, 0x66, 0x33, 0xff},
+ color.RGBA{0x33, 0x66, 0x66, 0xff},
+ color.RGBA{0x33, 0x66, 0x99, 0xff},
+ color.RGBA{0x33, 0x66, 0xcc, 0xff},
+ color.RGBA{0x33, 0x99, 0x00, 0xff},
+ color.RGBA{0x33, 0x99, 0x33, 0xff},
+ color.RGBA{0x33, 0x99, 0x66, 0xff},
+ color.RGBA{0x33, 0x99, 0x99, 0xff},
+ color.RGBA{0x33, 0x99, 0xcc, 0xff},
+ color.RGBA{0x33, 0xcc, 0x00, 0xff},
+ color.RGBA{0x33, 0xcc, 0x33, 0xff},
+ color.RGBA{0x33, 0xcc, 0x66, 0xff},
+ color.RGBA{0x33, 0xcc, 0x99, 0xff},
+ color.RGBA{0x33, 0xcc, 0xcc, 0xff},
+ color.RGBA{0x66, 0x00, 0x00, 0xff},
+ color.RGBA{0x66, 0x00, 0x33, 0xff},
+ color.RGBA{0x66, 0x00, 0x66, 0xff},
+ color.RGBA{0x66, 0x00, 0x99, 0xff},
+ color.RGBA{0x66, 0x00, 0xcc, 0xff},
+ color.RGBA{0x66, 0x33, 0x00, 0xff},
+ color.RGBA{0x66, 0x33, 0x33, 0xff},
+ color.RGBA{0x66, 0x33, 0x66, 0xff},
+ color.RGBA{0x66, 0x33, 0x99, 0xff},
+ color.RGBA{0x66, 0x33, 0xcc, 0xff},
+ color.RGBA{0x66, 0x66, 0x00, 0xff},
+ color.RGBA{0x66, 0x66, 0x33, 0xff},
+ color.RGBA{0x66, 0x66, 0x66, 0xff},
+ color.RGBA{0x66, 0x66, 0x99, 0xff},
+ color.RGBA{0x66, 0x66, 0xcc, 0xff},
+ color.RGBA{0x66, 0x99, 0x00, 0xff},
+ color.RGBA{0x66, 0x99, 0x33, 0xff},
+ color.RGBA{0x66, 0x99, 0x66, 0xff},
+ color.RGBA{0x66, 0x99, 0x99, 0xff},
+ color.RGBA{0x66, 0x99, 0xcc, 0xff},
+ color.RGBA{0x66, 0xcc, 0x00, 0xff},
+ color.RGBA{0x66, 0xcc, 0x33, 0xff},
+ color.RGBA{0x66, 0xcc, 0x66, 0xff},
+ color.RGBA{0x66, 0xcc, 0x99, 0xff},
+ color.RGBA{0x66, 0xcc, 0xcc, 0xff},
+ color.RGBA{0x99, 0x00, 0x00, 0xff},
+ color.RGBA{0x99, 0x00, 0x33, 0xff},
+ color.RGBA{0x99, 0x00, 0x66, 0xff},
+ color.RGBA{0x99, 0x00, 0x99, 0xff},
+ color.RGBA{0x99, 0x00, 0xcc, 0xff},
+ color.RGBA{0x99, 0x33, 0x00, 0xff},
+ color.RGBA{0x99, 0x33, 0x33, 0xff},
+ color.RGBA{0x99, 0x33, 0x66, 0xff},
+ color.RGBA{0x99, 0x33, 0x99, 0xff},
+ color.RGBA{0x99, 0x33, 0xcc, 0xff},
+ color.RGBA{0x99, 0x66, 0x00, 0xff},
+ color.RGBA{0x99, 0x66, 0x33, 0xff},
+ color.RGBA{0x99, 0x66, 0x66, 0xff},
+ color.RGBA{0x99, 0x66, 0x99, 0xff},
+ color.RGBA{0x99, 0x66, 0xcc, 0xff},
+ color.RGBA{0x99, 0x99, 0x00, 0xff},
+ color.RGBA{0x99, 0x99, 0x33, 0xff},
+ color.RGBA{0x99, 0x99, 0x66, 0xff},
+ color.RGBA{0x99, 0x99, 0x99, 0xff},
+ color.RGBA{0x99, 0x99, 0xcc, 0xff},
+ color.RGBA{0x99, 0xcc, 0x00, 0xff},
+ color.RGBA{0x99, 0xcc, 0x33, 0xff},
+ color.RGBA{0x99, 0xcc, 0x66, 0xff},
+ color.RGBA{0x99, 0xcc, 0x99, 0xff},
+ color.RGBA{0x99, 0xcc, 0xcc, 0xff},
+ color.RGBA{0xcc, 0x00, 0x00, 0xff},
+ color.RGBA{0xcc, 0x00, 0x33, 0xff},
+ color.RGBA{0xcc, 0x00, 0x66, 0xff},
+ color.RGBA{0xcc, 0x00, 0x99, 0xff},
+ color.RGBA{0xcc, 0x00, 0xcc, 0xff},
+ color.RGBA{0xcc, 0x33, 0x00, 0xff},
+ color.RGBA{0xcc, 0x33, 0x33, 0xff},
+ color.RGBA{0xcc, 0x33, 0x66, 0xff},
+ color.RGBA{0xcc, 0x33, 0x99, 0xff},
+ color.RGBA{0xcc, 0x33, 0xcc, 0xff},
+ color.RGBA{0xcc, 0x66, 0x00, 0xff},
+ color.RGBA{0xcc, 0x66, 0x33, 0xff},
+ color.RGBA{0xcc, 0x66, 0x66, 0xff},
+ color.RGBA{0xcc, 0x66, 0x99, 0xff},
+ color.RGBA{0xcc, 0x66, 0xcc, 0xff},
+ color.RGBA{0xcc, 0x99, 0x00, 0xff},
+ color.RGBA{0xcc, 0x99, 0x33, 0xff},
+ color.RGBA{0xcc, 0x99, 0x66, 0xff},
+ color.RGBA{0xcc, 0x99, 0x99, 0xff},
+ color.RGBA{0xcc, 0x99, 0xcc, 0xff},
+ color.RGBA{0xcc, 0xcc, 0x00, 0xff},
+ color.RGBA{0xcc, 0xcc, 0x33, 0xff},
+ color.RGBA{0xcc, 0xcc, 0x66, 0xff},
+ color.RGBA{0xcc, 0xcc, 0x99, 0xff},
+ color.RGBA{0xcc, 0xcc, 0xcc, 0xff},
+}
diff --git a/modules/avatar/identicon/identicon.go b/modules/avatar/identicon/identicon.go
new file mode 100644
index 0000000..4047156
--- /dev/null
+++ b/modules/avatar/identicon/identicon.go
@@ -0,0 +1,140 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Copied and modified from https://github.com/issue9/identicon/ (MIT License)
+// Generate pseudo-random avatars by IP, E-mail, etc.
+
+package identicon
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "image"
+ "image/color"
+)
+
+const minImageSize = 16
+
+// Identicon is used to generate pseudo-random avatars
+type Identicon struct {
+ foreColors []color.Color
+ backColor color.Color
+ size int
+ rect image.Rectangle
+}
+
+// New returns an Identicon struct with the correct settings
+// size image size
+// back background color
+// fore all possible foreground colors. only one foreground color will be picked randomly for one image
+func New(size int, back color.Color, fore ...color.Color) (*Identicon, error) {
+ if len(fore) == 0 {
+ return nil, fmt.Errorf("foreground is not set")
+ }
+
+ if size < minImageSize {
+ return nil, fmt.Errorf("size %d is smaller than min size %d", size, minImageSize)
+ }
+
+ return &Identicon{
+ foreColors: fore,
+ backColor: back,
+ size: size,
+ rect: image.Rect(0, 0, size, size),
+ }, nil
+}
+
+// Make generates an avatar by data
+func (i *Identicon) Make(data []byte) image.Image {
+ h := sha256.New()
+ h.Write(data)
+ sum := h.Sum(nil)
+
+ b1 := int(sum[0]+sum[1]+sum[2]) % len(blocks)
+ b2 := int(sum[3]+sum[4]+sum[5]) % len(blocks)
+ c := int(sum[6]+sum[7]+sum[8]) % len(centerBlocks)
+ b1Angle := int(sum[9]+sum[10]) % 4
+ b2Angle := int(sum[11]+sum[12]) % 4
+ foreColor := int(sum[11]+sum[12]+sum[15]) % len(i.foreColors)
+
+ return i.render(c, b1, b2, b1Angle, b2Angle, foreColor)
+}
+
+func (i *Identicon) render(c, b1, b2, b1Angle, b2Angle, foreColor int) image.Image {
+ p := image.NewPaletted(i.rect, []color.Color{i.backColor, i.foreColors[foreColor]})
+ drawBlocks(p, i.size, centerBlocks[c], blocks[b1], blocks[b2], b1Angle, b2Angle)
+ return p
+}
+
+/*
+# Algorithm
+
+Origin: An image is split into 9 areas
+
+```
+ -------------
+ | 1 | 2 | 3 |
+ -------------
+ | 4 | 5 | 6 |
+ -------------
+ | 7 | 8 | 9 |
+ -------------
+```
+
+Area 1/3/9/7 use a 90-degree rotating pattern.
+Area 1/3/9/7 use another 90-degree rotating pattern.
+Area 5 uses a random pattern.
+
+The Patched Fix: make the image left-right mirrored to get rid of something like "swastika"
+*/
+
+// draw blocks to the paletted
+// c: the block drawer for the center block
+// b1,b2: the block drawers for other blocks (around the center block)
+// b1Angle,b2Angle: the angle for the rotation of b1/b2
+func drawBlocks(p *image.Paletted, size int, c, b1, b2 blockFunc, b1Angle, b2Angle int) {
+ nextAngle := func(a int) int {
+ return (a + 1) % 4
+ }
+
+ padding := (size % 3) / 2 // in cased the size can not be aligned by 3 blocks.
+
+ blockSize := size / 3
+ twoBlockSize := 2 * blockSize
+
+ // center
+ c(p, blockSize+padding, blockSize+padding, blockSize, 0)
+
+ // left top (1)
+ b1(p, 0+padding, 0+padding, blockSize, b1Angle)
+ // center top (2)
+ b2(p, blockSize+padding, 0+padding, blockSize, b2Angle)
+
+ b1Angle = nextAngle(b1Angle)
+ b2Angle = nextAngle(b2Angle)
+ // right top (3)
+ // b1(p, twoBlockSize+padding, 0+padding, blockSize, b1Angle)
+ // right middle (6)
+ // b2(p, twoBlockSize+padding, blockSize+padding, blockSize, b2Angle)
+
+ b1Angle = nextAngle(b1Angle)
+ b2Angle = nextAngle(b2Angle)
+ // right bottom (9)
+ // b1(p, twoBlockSize+padding, twoBlockSize+padding, blockSize, b1Angle)
+ // center bottom (8)
+ b2(p, blockSize+padding, twoBlockSize+padding, blockSize, b2Angle)
+
+ b1Angle = nextAngle(b1Angle)
+ b2Angle = nextAngle(b2Angle)
+ // lef bottom (7)
+ b1(p, 0+padding, twoBlockSize+padding, blockSize, b1Angle)
+ // left middle (4)
+ b2(p, 0+padding, blockSize+padding, blockSize, b2Angle)
+
+ // then we make it left-right mirror, so we didn't draw 3/6/9 before
+ for x := 0; x < size/2; x++ {
+ for y := 0; y < size; y++ {
+ p.SetColorIndex(size-x, y, p.ColorIndexAt(x, y))
+ }
+ }
+}
diff --git a/modules/avatar/identicon/identicon_test.go b/modules/avatar/identicon/identicon_test.go
new file mode 100644
index 0000000..88702b0
--- /dev/null
+++ b/modules/avatar/identicon/identicon_test.go
@@ -0,0 +1,39 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build test_avatar_identicon
+
+package identicon
+
+import (
+ "image/color"
+ "image/png"
+ "os"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGenerate(t *testing.T) {
+ dir, _ := os.Getwd()
+ dir = dir + "/testdata"
+ if st, err := os.Stat(dir); err != nil || !st.IsDir() {
+ t.Errorf("can not save generated images to %s", dir)
+ }
+
+ backColor := color.White
+ imgMaker, err := New(64, backColor, DarkColors...)
+ require.NoError(t, err)
+ for i := 0; i < 100; i++ {
+ s := strconv.Itoa(i)
+ img := imgMaker.Make([]byte(s))
+
+ f, err := os.Create(dir + "/" + s + ".png")
+ require.NoError(t, err)
+
+ defer f.Close()
+ err = png.Encode(f, img)
+ require.NoError(t, err)
+ }
+}
diff --git a/modules/avatar/identicon/polygon.go b/modules/avatar/identicon/polygon.go
new file mode 100644
index 0000000..ecfc179
--- /dev/null
+++ b/modules/avatar/identicon/polygon.go
@@ -0,0 +1,68 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Copied and modified from https://github.com/issue9/identicon/ (MIT License)
+
+package identicon
+
+var (
+ // cos(0),cos(90),cos(180),cos(270)
+ cos = []int{1, 0, -1, 0}
+
+ // sin(0),sin(90),sin(180),sin(270)
+ sin = []int{0, 1, 0, -1}
+)
+
+// rotate the points by center point (x,y)
+// angle: [0,1,2,3] means [0,90,180,270] degree
+func rotate(points []int, x, y, angle int) {
+ // the angle is only used internally, and it has been guaranteed to be 0/1/2/3, so we do not check it again
+ for i := 0; i < len(points); i += 2 {
+ px, py := points[i]-x, points[i+1]-y
+ points[i] = px*cos[angle] - py*sin[angle] + x
+ points[i+1] = px*sin[angle] + py*cos[angle] + y
+ }
+}
+
+// check whether the point is inside the polygon (defined by the points)
+// the first and the last point must be the same
+func pointInPolygon(x, y int, polygonPoints []int) bool {
+ if len(polygonPoints) < 8 { // a valid polygon must have more than 2 points
+ return false
+ }
+
+ // reference: nonzero winding rule, https://en.wikipedia.org/wiki/Nonzero-rule
+ // split the plane into two by the check point horizontally:
+ // y>0,includes (x>0 && y==0)
+ // y<0,includes (x<0 && y==0)
+ //
+ // then scan every point in the polygon.
+ //
+ // if current point and previous point are in different planes (eg: curY>0 && prevY<0),
+ // check the clock-direction from previous point to current point (use check point as origin).
+ // if the direction is clockwise, then r++, otherwise then r--
+ // finally, if 2==abs(r), then the check point is inside the polygon
+
+ r := 0
+ prevX, prevY := polygonPoints[0], polygonPoints[1]
+ prev := (prevY > y) || ((prevX > x) && (prevY == y))
+ for i := 2; i < len(polygonPoints); i += 2 {
+ currX, currY := polygonPoints[i], polygonPoints[i+1]
+ curr := (currY > y) || ((currX > x) && (currY == y))
+
+ if curr == prev {
+ prevX, prevY = currX, currY
+ continue
+ }
+
+ if mul := (prevX-x)*(currY-y) - (currX-x)*(prevY-y); mul >= 0 {
+ r++
+ } else { // mul < 0
+ r--
+ }
+ prevX, prevY = currX, currY
+ prev = curr
+ }
+
+ return r == 2 || r == -2
+}
diff --git a/modules/avatar/identicon/testdata/.gitignore b/modules/avatar/identicon/testdata/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/modules/avatar/identicon/testdata/.gitignore
@@ -0,0 +1 @@
+*
diff --git a/modules/avatar/testdata/animated.webp b/modules/avatar/testdata/animated.webp
new file mode 100644
index 0000000..4c05f46
--- /dev/null
+++ b/modules/avatar/testdata/animated.webp
Binary files differ
diff --git a/modules/avatar/testdata/avatar.jpeg b/modules/avatar/testdata/avatar.jpeg
new file mode 100644
index 0000000..892b7ba
--- /dev/null
+++ b/modules/avatar/testdata/avatar.jpeg
Binary files differ
diff --git a/modules/avatar/testdata/avatar.png b/modules/avatar/testdata/avatar.png
new file mode 100644
index 0000000..c0f7922
--- /dev/null
+++ b/modules/avatar/testdata/avatar.png
Binary files differ
diff --git a/modules/base/base.go b/modules/base/base.go
new file mode 100644
index 0000000..dddce20
--- /dev/null
+++ b/modules/base/base.go
@@ -0,0 +1,9 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package base
+
+type (
+ // TplName template relative path type
+ TplName string
+)
diff --git a/modules/base/natural_sort.go b/modules/base/natural_sort.go
new file mode 100644
index 0000000..5b5febb
--- /dev/null
+++ b/modules/base/natural_sort.go
@@ -0,0 +1,90 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package base
+
+import (
+ "math/big"
+ "strings"
+ "unicode/utf8"
+)
+
+// NaturalSortLess compares two strings so that they could be sorted in natural order
+func NaturalSortLess(s1, s2 string) bool {
+ s1, s2 = strings.ToLower(s1), strings.ToLower(s2)
+ var i1, i2 int
+ for {
+ rune1, j1, end1 := getNextRune(s1, i1)
+ rune2, j2, end2 := getNextRune(s2, i2)
+ if end1 || end2 {
+ return end1 != end2 && end1
+ }
+ dec1 := isDecimal(rune1)
+ dec2 := isDecimal(rune2)
+ var less, equal bool
+ if dec1 && dec2 {
+ i1, i2, less, equal = compareByNumbers(s1, i1, s2, i2)
+ } else if !dec1 && !dec2 {
+ equal = rune1 == rune2
+ less = rune1 < rune2
+ i1 = j1
+ i2 = j2
+ } else {
+ return rune1 < rune2
+ }
+ if !equal {
+ return less
+ }
+ }
+}
+
+func getNextRune(str string, pos int) (rune, int, bool) {
+ if pos < len(str) {
+ r, w := utf8.DecodeRuneInString(str[pos:])
+ // Fallback to ascii
+ if r == utf8.RuneError {
+ r = rune(str[pos])
+ w = 1
+ }
+ return r, pos + w, false
+ }
+ return 0, pos, true
+}
+
+func isDecimal(r rune) bool {
+ return '0' <= r && r <= '9'
+}
+
+func compareByNumbers(str1 string, pos1 int, str2 string, pos2 int) (i1, i2 int, less, equal bool) {
+ d1, d2 := true, true
+ var dec1, dec2 string
+ for d1 || d2 {
+ if d1 {
+ r, j, end := getNextRune(str1, pos1)
+ if !end && isDecimal(r) {
+ dec1 += string(r)
+ pos1 = j
+ } else {
+ d1 = false
+ }
+ }
+ if d2 {
+ r, j, end := getNextRune(str2, pos2)
+ if !end && isDecimal(r) {
+ dec2 += string(r)
+ pos2 = j
+ } else {
+ d2 = false
+ }
+ }
+ }
+ less, equal = compareBigNumbers(dec1, dec2)
+ return pos1, pos2, less, equal
+}
+
+func compareBigNumbers(dec1, dec2 string) (less, equal bool) {
+ d1, _ := big.NewInt(0).SetString(dec1, 10)
+ d2, _ := big.NewInt(0).SetString(dec2, 10)
+ cmp := d1.Cmp(d2)
+ return cmp < 0, cmp == 0
+}
diff --git a/modules/base/natural_sort_test.go b/modules/base/natural_sort_test.go
new file mode 100644
index 0000000..7378d9a
--- /dev/null
+++ b/modules/base/natural_sort_test.go
@@ -0,0 +1,29 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package base
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNaturalSortLess(t *testing.T) {
+ test := func(s1, s2 string, less bool) {
+ assert.Equal(t, less, NaturalSortLess(s1, s2))
+ }
+ test("v1.20.0", "v1.2.0", false)
+ test("v1.20.0", "v1.29.0", true)
+ test("v1.20.0", "v1.20.0", false)
+ test("abc", "bcd", true)
+ test("a-1-a", "a-1-b", true)
+ test("2", "12", true)
+ test("a", "ab", true)
+
+ // Test for case insensitive.
+ test("A", "ab", true)
+ test("B", "ab", false)
+ test("a", "AB", true)
+ test("b", "AB", false)
+}
diff --git a/modules/base/tool.go b/modules/base/tool.go
new file mode 100644
index 0000000..7612fff
--- /dev/null
+++ b/modules/base/tool.go
@@ -0,0 +1,234 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package base
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "hash"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/dustin/go-humanize"
+)
+
+// EncodeSha256 string to sha256 hex value.
+func EncodeSha256(str string) string {
+ h := sha256.New()
+ _, _ = h.Write([]byte(str))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// ShortSha is basically just truncating.
+// It is DEPRECATED and will be removed in the future.
+func ShortSha(sha1 string) string {
+ return TruncateString(sha1, 10)
+}
+
+// BasicAuthDecode decode basic auth string
+func BasicAuthDecode(encoded string) (string, string, error) {
+ s, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", "", err
+ }
+
+ if username, password, ok := strings.Cut(string(s), ":"); ok {
+ return username, password, nil
+ }
+ return "", "", errors.New("invalid basic authentication")
+}
+
+// VerifyTimeLimitCode verify time limit code
+func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
+ if len(code) <= 18 {
+ return false
+ }
+
+ startTimeStr := code[:12]
+ aliveTimeStr := code[12:18]
+ aliveTime, _ := strconv.Atoi(aliveTimeStr) // no need to check err, if anything wrong, the following code check will fail soon
+
+ // check code
+ retCode := CreateTimeLimitCode(data, aliveTime, startTimeStr, nil)
+ if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
+ retCode = CreateTimeLimitCode(data, aliveTime, startTimeStr, sha1.New()) // TODO: this is only for the support of legacy codes, remove this in/after 1.23
+ if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
+ return false
+ }
+ }
+
+ // check time is expired or not: startTime <= now && now < startTime + minutes
+ startTime, _ := time.ParseInLocation("200601021504", startTimeStr, time.Local)
+ return (startTime.Before(now) || startTime.Equal(now)) && now.Before(startTime.Add(time.Minute*time.Duration(minutes)))
+}
+
+// TimeLimitCodeLength default value for time limit code
+const TimeLimitCodeLength = 12 + 6 + 40
+
+// CreateTimeLimitCode create a time-limited code.
+// Format: 12 length date time string + 6 minutes string (not used) + 40 hash string, some other code depends on this fixed length
+// If h is nil, then use the default hmac hash.
+func CreateTimeLimitCode[T time.Time | string](data string, minutes int, startTimeGeneric T, h hash.Hash) string {
+ const format = "200601021504"
+
+ var start time.Time
+ var startTimeAny any = startTimeGeneric
+ if t, ok := startTimeAny.(time.Time); ok {
+ start = t
+ } else {
+ var err error
+ start, err = time.ParseInLocation(format, startTimeAny.(string), time.Local)
+ if err != nil {
+ return "" // return an invalid code because the "parse" failed
+ }
+ }
+ startStr := start.Format(format)
+ end := start.Add(time.Minute * time.Duration(minutes))
+
+ if h == nil {
+ h = hmac.New(sha1.New, setting.GetGeneralTokenSigningSecret())
+ }
+ _, _ = fmt.Fprintf(h, "%s%s%s%s%d", data, hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), startStr, end.Format(format), minutes)
+ encoded := hex.EncodeToString(h.Sum(nil))
+
+ code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded)
+ if len(code) != TimeLimitCodeLength {
+ panic("there is a hard requirement for the length of time-limited code") // it shouldn't happen
+ }
+ return code
+}
+
+// FileSize calculates the file size and generate user-friendly string.
+func FileSize(s int64) string {
+ return humanize.IBytes(uint64(s))
+}
+
+// EllipsisString returns a truncated short string,
+// it appends '...' in the end of the length of string is too large.
+func EllipsisString(str string, length int) string {
+ if length <= 3 {
+ return "..."
+ }
+ if utf8.RuneCountInString(str) <= length {
+ return str
+ }
+ return string([]rune(str)[:length-3]) + "..."
+}
+
+// TruncateString returns a truncated string with given limit,
+// it returns input string if length is not reached limit.
+func TruncateString(str string, limit int) string {
+ if utf8.RuneCountInString(str) < limit {
+ return str
+ }
+ return string([]rune(str)[:limit])
+}
+
+// StringsToInt64s converts a slice of string to a slice of int64.
+func StringsToInt64s(strs []string) ([]int64, error) {
+ if strs == nil {
+ return nil, nil
+ }
+ ints := make([]int64, 0, len(strs))
+ for _, s := range strs {
+ n, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ ints = append(ints, n)
+ }
+ return ints, nil
+}
+
+// Int64sToStrings converts a slice of int64 to a slice of string.
+func Int64sToStrings(ints []int64) []string {
+ strs := make([]string, len(ints))
+ for i := range ints {
+ strs[i] = strconv.FormatInt(ints[i], 10)
+ }
+ return strs
+}
+
+// EntryIcon returns the octicon class for displaying files/directories
+func EntryIcon(entry *git.TreeEntry) string {
+ switch {
+ case entry.IsLink():
+ te, _, err := entry.FollowLink()
+ if err != nil {
+ log.Debug(err.Error())
+ return "file-symlink-file"
+ }
+ if te.IsDir() {
+ return "file-directory-symlink"
+ }
+ return "file-symlink-file"
+ case entry.IsDir():
+ return "file-directory-fill"
+ case entry.IsSubModule():
+ return "file-submodule"
+ }
+
+ return "file"
+}
+
+// SetupGiteaRoot Sets GITEA_ROOT if it is not already set and returns the value
+func SetupGiteaRoot() string {
+ giteaRoot := os.Getenv("GITEA_ROOT")
+ if giteaRoot == "" {
+ _, filename, _, _ := runtime.Caller(0)
+ giteaRoot = strings.TrimSuffix(filename, "modules/base/tool.go")
+ wd, err := os.Getwd()
+ if err != nil {
+ rel, err := filepath.Rel(giteaRoot, wd)
+ if err != nil && strings.HasPrefix(filepath.ToSlash(rel), "../") {
+ giteaRoot = wd
+ }
+ }
+ if _, err := os.Stat(filepath.Join(giteaRoot, "gitea")); os.IsNotExist(err) {
+ giteaRoot = ""
+ } else if err := os.Setenv("GITEA_ROOT", giteaRoot); err != nil {
+ giteaRoot = ""
+ }
+ }
+ return giteaRoot
+}
+
+// FormatNumberSI format a number
+func FormatNumberSI(data any) string {
+ var num int64
+ if num1, ok := data.(int64); ok {
+ num = num1
+ } else if num1, ok := data.(int); ok {
+ num = int64(num1)
+ } else {
+ return ""
+ }
+
+ if num < 1000 {
+ return fmt.Sprintf("%d", num)
+ } else if num < 1000000 {
+ num2 := float32(num) / float32(1000.0)
+ return fmt.Sprintf("%.1fk", num2)
+ } else if num < 1000000000 {
+ num2 := float32(num) / float32(1000000.0)
+ return fmt.Sprintf("%.1fM", num2)
+ }
+ num2 := float32(num) / float32(1000000000.0)
+ return fmt.Sprintf("%.1fG", num2)
+}
diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go
new file mode 100644
index 0000000..81fd4b6
--- /dev/null
+++ b/modules/base/tool_test.go
@@ -0,0 +1,186 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package base
+
+import (
+ "crypto/sha1"
+ "fmt"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEncodeSha256(t *testing.T) {
+ assert.Equal(t,
+ "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2",
+ EncodeSha256("foobar"),
+ )
+}
+
+func TestShortSha(t *testing.T) {
+ assert.Equal(t, "veryverylo", ShortSha("veryverylong"))
+}
+
+func TestBasicAuthDecode(t *testing.T) {
+ _, _, err := BasicAuthDecode("?")
+ assert.Equal(t, "illegal base64 data at input byte 0", err.Error())
+
+ user, pass, err := BasicAuthDecode("Zm9vOmJhcg==")
+ require.NoError(t, err)
+ assert.Equal(t, "foo", user)
+ assert.Equal(t, "bar", pass)
+
+ _, _, err = BasicAuthDecode("aW52YWxpZA==")
+ require.Error(t, err)
+
+ _, _, err = BasicAuthDecode("invalid")
+ require.Error(t, err)
+
+ _, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon
+ require.Error(t, err)
+}
+
+func TestVerifyTimeLimitCode(t *testing.T) {
+ defer test.MockVariableValue(&setting.InstallLock, true)()
+ initGeneralSecret := func(secret string) {
+ setting.InstallLock = true
+ setting.CfgProvider, _ = setting.NewConfigProviderFromData(fmt.Sprintf(`
+[oauth2]
+JWT_SECRET = %s
+`, secret))
+ setting.LoadCommonSettings()
+ }
+
+ initGeneralSecret("KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko")
+ now := time.Now()
+
+ t.Run("TestGenericParameter", func(t *testing.T) {
+ time2000 := time.Date(2000, 1, 2, 3, 4, 5, 0, time.Local)
+ assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, time2000, sha1.New()))
+ assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, "200001020304", sha1.New()))
+ assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, time2000, nil))
+ assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, "200001020304", nil))
+ })
+
+ t.Run("TestInvalidCode", func(t *testing.T) {
+ assert.False(t, VerifyTimeLimitCode(now, "data", 2, ""))
+ assert.False(t, VerifyTimeLimitCode(now, "data", 2, "invalid code"))
+ })
+
+ t.Run("TestCreateAndVerify", func(t *testing.T) {
+ code := CreateTimeLimitCode("data", 2, now, nil)
+ assert.False(t, VerifyTimeLimitCode(now.Add(-time.Minute), "data", 2, code)) // not started yet
+ assert.True(t, VerifyTimeLimitCode(now, "data", 2, code))
+ assert.True(t, VerifyTimeLimitCode(now.Add(time.Minute), "data", 2, code))
+ assert.False(t, VerifyTimeLimitCode(now.Add(time.Minute), "DATA", 2, code)) // invalid data
+ assert.False(t, VerifyTimeLimitCode(now.Add(2*time.Minute), "data", 2, code)) // expired
+ })
+
+ t.Run("TestDifferentSecret", func(t *testing.T) {
+ // use another secret to ensure the code is invalid for different secret
+ verifyDataCode := func(c string) bool {
+ return VerifyTimeLimitCode(now, "data", 2, c)
+ }
+ code1 := CreateTimeLimitCode("data", 2, now, sha1.New())
+ code2 := CreateTimeLimitCode("data", 2, now, nil)
+ assert.True(t, verifyDataCode(code1))
+ assert.True(t, verifyDataCode(code2))
+ initGeneralSecret("000_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko")
+ assert.False(t, verifyDataCode(code1))
+ assert.False(t, verifyDataCode(code2))
+ })
+}
+
+func TestFileSize(t *testing.T) {
+ var size int64 = 512
+ assert.Equal(t, "512 B", FileSize(size))
+ size *= 1024
+ assert.Equal(t, "512 KiB", FileSize(size))
+ size *= 1024
+ assert.Equal(t, "512 MiB", FileSize(size))
+ size *= 1024
+ assert.Equal(t, "512 GiB", FileSize(size))
+ size *= 1024
+ assert.Equal(t, "512 TiB", FileSize(size))
+ size *= 1024
+ assert.Equal(t, "512 PiB", FileSize(size))
+ size *= 4
+ assert.Equal(t, "2.0 EiB", FileSize(size))
+}
+
+func TestEllipsisString(t *testing.T) {
+ assert.Equal(t, "...", EllipsisString("foobar", 0))
+ assert.Equal(t, "...", EllipsisString("foobar", 1))
+ assert.Equal(t, "...", EllipsisString("foobar", 2))
+ assert.Equal(t, "...", EllipsisString("foobar", 3))
+ assert.Equal(t, "f...", EllipsisString("foobar", 4))
+ assert.Equal(t, "fo...", EllipsisString("foobar", 5))
+ assert.Equal(t, "foobar", EllipsisString("foobar", 6))
+ assert.Equal(t, "foobar", EllipsisString("foobar", 10))
+ assert.Equal(t, "测...", EllipsisString("测试文本一二三四", 4))
+ assert.Equal(t, "测试...", EllipsisString("测试文本一二三四", 5))
+ assert.Equal(t, "测试文...", EllipsisString("测试文本一二三四", 6))
+ assert.Equal(t, "测试文本一二三四", EllipsisString("测试文本一二三四", 10))
+}
+
+func TestTruncateString(t *testing.T) {
+ assert.Equal(t, "", TruncateString("foobar", 0))
+ assert.Equal(t, "f", TruncateString("foobar", 1))
+ assert.Equal(t, "fo", TruncateString("foobar", 2))
+ assert.Equal(t, "foo", TruncateString("foobar", 3))
+ assert.Equal(t, "foob", TruncateString("foobar", 4))
+ assert.Equal(t, "fooba", TruncateString("foobar", 5))
+ assert.Equal(t, "foobar", TruncateString("foobar", 6))
+ assert.Equal(t, "foobar", TruncateString("foobar", 7))
+ assert.Equal(t, "测试文本", TruncateString("测试文本一二三四", 4))
+ assert.Equal(t, "测试文本一", TruncateString("测试文本一二三四", 5))
+ assert.Equal(t, "测试文本一二", TruncateString("测试文本一二三四", 6))
+ assert.Equal(t, "测试文本一二三", TruncateString("测试文本一二三四", 7))
+}
+
+func TestStringsToInt64s(t *testing.T) {
+ testSuccess := func(input []string, expected []int64) {
+ result, err := StringsToInt64s(input)
+ require.NoError(t, err)
+ assert.Equal(t, expected, result)
+ }
+ testSuccess(nil, nil)
+ testSuccess([]string{}, []int64{})
+ testSuccess([]string{"-1234"}, []int64{-1234})
+ testSuccess([]string{"1", "4", "16", "64", "256"}, []int64{1, 4, 16, 64, 256})
+
+ ints, err := StringsToInt64s([]string{"-1", "a"})
+ assert.Empty(t, ints)
+ require.Error(t, err)
+}
+
+func TestInt64sToStrings(t *testing.T) {
+ assert.Equal(t, []string{}, Int64sToStrings([]int64{}))
+ assert.Equal(t,
+ []string{"1", "4", "16", "64", "256"},
+ Int64sToStrings([]int64{1, 4, 16, 64, 256}),
+ )
+}
+
+// TODO: Test EntryIcon
+
+func TestSetupGiteaRoot(t *testing.T) {
+ t.Setenv("GITEA_ROOT", "test")
+ assert.Equal(t, "test", SetupGiteaRoot())
+ t.Setenv("GITEA_ROOT", "")
+ assert.NotEqual(t, "test", SetupGiteaRoot())
+}
+
+func TestFormatNumberSI(t *testing.T) {
+ assert.Equal(t, "125", FormatNumberSI(int(125)))
+ assert.Equal(t, "1.3k", FormatNumberSI(int64(1317)))
+ assert.Equal(t, "21.3M", FormatNumberSI(21317675))
+ assert.Equal(t, "45.7G", FormatNumberSI(45721317675))
+ assert.Equal(t, "", FormatNumberSI("test"))
+}
diff --git a/modules/cache/cache.go b/modules/cache/cache.go
new file mode 100644
index 0000000..2148e02
--- /dev/null
+++ b/modules/cache/cache.go
@@ -0,0 +1,184 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ mc "code.forgejo.org/go-chi/cache"
+
+ _ "code.forgejo.org/go-chi/cache/memcache" // memcache plugin for cache
+)
+
+var conn mc.Cache
+
+func newCache(cacheConfig setting.Cache) (mc.Cache, error) {
+ return mc.NewCacher(mc.Options{
+ Adapter: cacheConfig.Adapter,
+ AdapterConfig: cacheConfig.Conn,
+ Interval: cacheConfig.Interval,
+ })
+}
+
+// Init start cache service
+func Init() error {
+ var err error
+
+ if conn == nil {
+ if conn, err = newCache(setting.CacheService.Cache); err != nil {
+ return err
+ }
+ if err = conn.Ping(); err != nil {
+ return err
+ }
+ }
+
+ return err
+}
+
+const (
+ testCacheKey = "DefaultCache.TestKey"
+ SlowCacheThreshold = 100 * time.Microsecond
+)
+
+func Test() (time.Duration, error) {
+ if conn == nil {
+ return 0, fmt.Errorf("default cache not initialized")
+ }
+
+ testData := fmt.Sprintf("%x", make([]byte, 500))
+
+ start := time.Now()
+
+ if err := conn.Delete(testCacheKey); err != nil {
+ return 0, fmt.Errorf("expect cache to delete data based on key if exist but got: %w", err)
+ }
+ if err := conn.Put(testCacheKey, testData, 10); err != nil {
+ return 0, fmt.Errorf("expect cache to store data but got: %w", err)
+ }
+ testVal := conn.Get(testCacheKey)
+ if testVal == nil {
+ return 0, fmt.Errorf("expect cache hit but got none")
+ }
+ if testVal != testData {
+ return 0, fmt.Errorf("expect cache to return same value as stored but got other")
+ }
+
+ return time.Since(start), nil
+}
+
+// GetCache returns the currently configured cache
+func GetCache() mc.Cache {
+ return conn
+}
+
+// GetString returns the key value from cache with callback when no key exists in cache
+func GetString(key string, getFunc func() (string, error)) (string, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ if value, ok := cached.(string); ok {
+ return value, nil
+ }
+
+ if stringer, ok := cached.(fmt.Stringer); ok {
+ return stringer.String(), nil
+ }
+
+ return fmt.Sprintf("%s", cached), nil
+}
+
+// GetInt returns key value from cache with callback when no key exists in cache
+func GetInt(key string, getFunc func() (int, error)) (int, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ switch v := cached.(type) {
+ case int:
+ return v, nil
+ case string:
+ value, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, err
+ }
+ return value, nil
+ default:
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+}
+
+// GetInt64 returns key value from cache with callback when no key exists in cache
+func GetInt64(key string, getFunc func() (int64, error)) (int64, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ switch v := conn.Get(key).(type) {
+ case int64:
+ return v, nil
+ case string:
+ value, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ return value, nil
+ default:
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+}
+
+// Remove key from cache
+func Remove(key string) {
+ if conn == nil {
+ return
+ }
+ _ = conn.Delete(key)
+}
diff --git a/modules/cache/cache_redis.go b/modules/cache/cache_redis.go
new file mode 100644
index 0000000..4c243b2
--- /dev/null
+++ b/modules/cache/cache_redis.go
@@ -0,0 +1,161 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/nosql"
+
+ "code.forgejo.org/go-chi/cache"
+)
+
+// RedisCacher represents a redis cache adapter implementation.
+type RedisCacher struct {
+ c nosql.RedisClient
+ prefix string
+ hsetName string
+ occupyMode bool
+}
+
+// toStr convert string/int/int64 interface to string. it's only used by the RedisCacher.Put internally
+func toStr(v any) string {
+ if v == nil {
+ return ""
+ }
+ switch v := v.(type) {
+ case string:
+ return v
+ case []byte:
+ return string(v)
+ case int:
+ return strconv.FormatInt(int64(v), 10)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ default:
+ return fmt.Sprint(v) // as what the old com.ToStr does in most cases
+ }
+}
+
+// Put puts value (string type) into cache with key and expire time.
+// If expired is 0, it lives forever.
+func (c *RedisCacher) Put(key string, val any, expire int64) error {
+ // this function is not well-designed, it only puts string values into cache
+ key = c.prefix + key
+ if expire == 0 {
+ if err := c.c.Set(graceful.GetManager().HammerContext(), key, toStr(val), 0).Err(); err != nil {
+ return err
+ }
+ } else {
+ dur := time.Duration(expire) * time.Second
+ if err := c.c.Set(graceful.GetManager().HammerContext(), key, toStr(val), dur).Err(); err != nil {
+ return err
+ }
+ }
+
+ if c.occupyMode {
+ return nil
+ }
+ return c.c.HSet(graceful.GetManager().HammerContext(), c.hsetName, key, "0").Err()
+}
+
+// Get gets cached value by given key.
+func (c *RedisCacher) Get(key string) any {
+ val, err := c.c.Get(graceful.GetManager().HammerContext(), c.prefix+key).Result()
+ if err != nil {
+ return nil
+ }
+ return val
+}
+
+// Delete deletes cached value by given key.
+func (c *RedisCacher) Delete(key string) error {
+ key = c.prefix + key
+ if err := c.c.Del(graceful.GetManager().HammerContext(), key).Err(); err != nil {
+ return err
+ }
+
+ if c.occupyMode {
+ return nil
+ }
+ return c.c.HDel(graceful.GetManager().HammerContext(), c.hsetName, key).Err()
+}
+
+// Incr increases cached int-type value by given key as a counter.
+func (c *RedisCacher) Incr(key string) error {
+ if !c.IsExist(key) {
+ return fmt.Errorf("key '%s' not exist", key)
+ }
+ return c.c.Incr(graceful.GetManager().HammerContext(), c.prefix+key).Err()
+}
+
+// Decr decreases cached int-type value by given key as a counter.
+func (c *RedisCacher) Decr(key string) error {
+ if !c.IsExist(key) {
+ return fmt.Errorf("key '%s' not exist", key)
+ }
+ return c.c.Decr(graceful.GetManager().HammerContext(), c.prefix+key).Err()
+}
+
+// IsExist returns true if cached value exists.
+func (c *RedisCacher) IsExist(key string) bool {
+ if c.c.Exists(graceful.GetManager().HammerContext(), c.prefix+key).Val() == 1 {
+ return true
+ }
+
+ if !c.occupyMode {
+ c.c.HDel(graceful.GetManager().HammerContext(), c.hsetName, c.prefix+key)
+ }
+ return false
+}
+
+// Flush deletes all cached data.
+func (c *RedisCacher) Flush() error {
+ if c.occupyMode {
+ return c.c.FlushDB(graceful.GetManager().HammerContext()).Err()
+ }
+
+ keys, err := c.c.HKeys(graceful.GetManager().HammerContext(), c.hsetName).Result()
+ if err != nil {
+ return err
+ }
+ if err = c.c.Del(graceful.GetManager().HammerContext(), keys...).Err(); err != nil {
+ return err
+ }
+ return c.c.Del(graceful.GetManager().HammerContext(), c.hsetName).Err()
+}
+
+// StartAndGC starts GC routine based on config string settings.
+// AdapterConfig: network=tcp,addr=:6379,password=macaron,db=0,pool_size=100,idle_timeout=180,hset_name=MacaronCache,prefix=cache:
+func (c *RedisCacher) StartAndGC(opts cache.Options) error {
+ c.hsetName = "MacaronCache"
+ c.occupyMode = opts.OccupyMode
+
+ uri := nosql.ToRedisURI(opts.AdapterConfig)
+
+ c.c = nosql.GetManager().GetRedisClient(uri.String())
+
+ for k, v := range uri.Query() {
+ switch k {
+ case "hset_name":
+ c.hsetName = v[0]
+ case "prefix":
+ c.prefix = v[0]
+ }
+ }
+
+ return c.c.Ping(graceful.GetManager().HammerContext()).Err()
+}
+
+// Ping tests if the cache is alive.
+func (c *RedisCacher) Ping() error {
+ return c.c.Ping(graceful.GetManager().HammerContext()).Err()
+}
+
+func init() {
+ cache.Register("redis", &RedisCacher{})
+}
diff --git a/modules/cache/cache_test.go b/modules/cache/cache_test.go
new file mode 100644
index 0000000..8bc986f
--- /dev/null
+++ b/modules/cache/cache_test.go
@@ -0,0 +1,150 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createTestCache() {
+ conn, _ = newCache(setting.Cache{
+ Adapter: "memory",
+ TTL: time.Minute,
+ })
+ setting.CacheService.TTL = 24 * time.Hour
+}
+
+func TestNewContext(t *testing.T) {
+ require.NoError(t, Init())
+
+ setting.CacheService.Cache = setting.Cache{Adapter: "redis", Conn: "some random string"}
+ con, err := newCache(setting.Cache{
+ Adapter: "rand",
+ Conn: "false conf",
+ Interval: 100,
+ })
+ require.Error(t, err)
+ assert.Nil(t, con)
+}
+
+func TestGetCache(t *testing.T) {
+ createTestCache()
+
+ assert.NotNil(t, GetCache())
+}
+
+func TestGetString(t *testing.T) {
+ createTestCache()
+
+ data, err := GetString("key", func() (string, error) {
+ return "", fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.Equal(t, "", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "some data", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "", data)
+ Remove("key")
+
+ data, err = GetString("key", func() (string, error) {
+ return "some data", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "some data", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "", fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "some data", data)
+ Remove("key")
+}
+
+func TestGetInt(t *testing.T) {
+ createTestCache()
+
+ data, err := GetInt("key", func() (int, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.Equal(t, 0, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 0, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0, data)
+ Remove("key")
+
+ data, err = GetInt("key", func() (int, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 100, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 100, data)
+ Remove("key")
+}
+
+func TestGetInt64(t *testing.T) {
+ createTestCache()
+
+ data, err := GetInt64("key", func() (int64, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.EqualValues(t, 0, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 0, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, data)
+ Remove("key")
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 100, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 100, data)
+ Remove("key")
+}
diff --git a/modules/cache/cache_twoqueue.go b/modules/cache/cache_twoqueue.go
new file mode 100644
index 0000000..c15ed52
--- /dev/null
+++ b/modules/cache/cache_twoqueue.go
@@ -0,0 +1,208 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "strconv"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+
+ mc "code.forgejo.org/go-chi/cache"
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+// TwoQueueCache represents a LRU 2Q cache adapter implementation
+type TwoQueueCache struct {
+ lock sync.Mutex
+ cache *lru.TwoQueueCache[string, any]
+ interval int
+}
+
+// TwoQueueCacheConfig describes the configuration for TwoQueueCache
+type TwoQueueCacheConfig struct {
+ Size int `ini:"SIZE" json:"size"`
+ RecentRatio float64 `ini:"RECENT_RATIO" json:"recent_ratio"`
+ GhostRatio float64 `ini:"GHOST_RATIO" json:"ghost_ratio"`
+}
+
+// MemoryItem represents a memory cache item.
+type MemoryItem struct {
+ Val any
+ Created int64
+ Timeout int64
+}
+
+func (item *MemoryItem) hasExpired() bool {
+ return item.Timeout > 0 &&
+ (time.Now().Unix()-item.Created) >= item.Timeout
+}
+
+var _ mc.Cache = &TwoQueueCache{}
+
+// Put puts value into cache with key and expire time.
+func (c *TwoQueueCache) Put(key string, val any, timeout int64) error {
+ item := &MemoryItem{
+ Val: val,
+ Created: time.Now().Unix(),
+ Timeout: timeout,
+ }
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Add(key, item)
+ return nil
+}
+
+// Get gets cached value by given key.
+func (c *TwoQueueCache) Get(key string) any {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ return item.Val
+}
+
+// Delete deletes cached value by given key.
+func (c *TwoQueueCache) Delete(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Remove(key)
+ return nil
+}
+
+// Incr increases cached int-type value by given key as a counter.
+func (c *TwoQueueCache) Incr(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ var err error
+ item.Val, err = mc.Incr(item.Val)
+ return err
+}
+
+// Decr decreases cached int-type value by given key as a counter.
+func (c *TwoQueueCache) Decr(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ var err error
+ item.Val, err = mc.Decr(item.Val)
+ return err
+}
+
+// IsExist returns true if cached value exists.
+func (c *TwoQueueCache) IsExist(key string) bool {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Peek(key)
+ if !ok {
+ return false
+ }
+ item, ok := cached.(*MemoryItem)
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return false
+ }
+
+ return true
+}
+
+// Flush deletes all cached data.
+func (c *TwoQueueCache) Flush() error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Purge()
+ return nil
+}
+
+func (c *TwoQueueCache) checkAndInvalidate(key string) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Peek(key)
+ if !ok {
+ return
+ }
+ item, ok := cached.(*MemoryItem)
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ }
+}
+
+func (c *TwoQueueCache) startGC() {
+ if c.interval < 0 {
+ return
+ }
+ for _, key := range c.cache.Keys() {
+ c.checkAndInvalidate(key)
+ }
+ time.AfterFunc(time.Duration(c.interval)*time.Second, c.startGC)
+}
+
+// StartAndGC starts GC routine based on config string settings.
+func (c *TwoQueueCache) StartAndGC(opts mc.Options) error {
+ var err error
+ size := 50000
+ if opts.AdapterConfig != "" {
+ size, err = strconv.Atoi(opts.AdapterConfig)
+ }
+ if err != nil {
+ if !json.Valid([]byte(opts.AdapterConfig)) {
+ return err
+ }
+
+ cfg := &TwoQueueCacheConfig{
+ Size: 50000,
+ RecentRatio: lru.Default2QRecentRatio,
+ GhostRatio: lru.Default2QGhostEntries,
+ }
+ _ = json.Unmarshal([]byte(opts.AdapterConfig), cfg)
+ c.cache, err = lru.New2QParams[string, any](cfg.Size, cfg.RecentRatio, cfg.GhostRatio)
+ } else {
+ c.cache, err = lru.New2Q[string, any](size)
+ }
+ c.interval = opts.Interval
+ if c.interval > 0 {
+ go c.startGC()
+ }
+ return err
+}
+
+// Ping tests if the cache is alive.
+func (c *TwoQueueCache) Ping() error {
+ return mc.GenericPing(c)
+}
+
+func init() {
+ mc.Register("twoqueue", &TwoQueueCache{})
+}
diff --git a/modules/cache/context.go b/modules/cache/context.go
new file mode 100644
index 0000000..f9bdf52
--- /dev/null
+++ b/modules/cache/context.go
@@ -0,0 +1,181 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// cacheContext is a context that can be used to cache data in a request level context
+// This is useful for caching data that is expensive to calculate and is likely to be
+// used multiple times in a request.
+type cacheContext struct {
+ data map[any]map[any]any
+ lock sync.RWMutex
+ created time.Time
+ discard bool
+}
+
+func (cc *cacheContext) Get(tp, key any) any {
+ cc.lock.RLock()
+ defer cc.lock.RUnlock()
+ return cc.data[tp][key]
+}
+
+func (cc *cacheContext) Put(tp, key, value any) {
+ cc.lock.Lock()
+ defer cc.lock.Unlock()
+
+ if cc.discard {
+ return
+ }
+
+ d := cc.data[tp]
+ if d == nil {
+ d = make(map[any]any)
+ cc.data[tp] = d
+ }
+ d[key] = value
+}
+
+func (cc *cacheContext) Delete(tp, key any) {
+ cc.lock.Lock()
+ defer cc.lock.Unlock()
+ delete(cc.data[tp], key)
+}
+
+func (cc *cacheContext) Discard() {
+ cc.lock.Lock()
+ defer cc.lock.Unlock()
+ cc.data = nil
+ cc.discard = true
+}
+
+func (cc *cacheContext) isDiscard() bool {
+ cc.lock.RLock()
+ defer cc.lock.RUnlock()
+ return cc.discard
+}
+
+// cacheContextLifetime is the max lifetime of cacheContext.
+// Since cacheContext is used to cache data in a request level context, 5 minutes is enough.
+// If a cacheContext is used more than 5 minutes, it's probably misuse.
+const cacheContextLifetime = 5 * time.Minute
+
+var timeNow = time.Now
+
+func (cc *cacheContext) Expired() bool {
+ return timeNow().Sub(cc.created) > cacheContextLifetime
+}
+
+type cacheContextType = struct{ useless struct{} }
+
+var cacheContextKey = cacheContextType{useless: struct{}{}}
+
+/*
+Since there are both WithCacheContext and WithNoCacheContext,
+it may be confusing when there is nesting.
+
+Some cases to explain the design:
+
+When:
+- A, B or C means a cache context.
+- A', B' or C' means a discard cache context.
+- ctx means context.Backgrand().
+- A(ctx) means a cache context with ctx as the parent context.
+- B(A(ctx)) means a cache context with A(ctx) as the parent context.
+- With is alias of WithCacheContext.
+- WithNo is alias of WithNoCacheContext.
+
+So:
+- With(ctx) -> A(ctx)
+- With(With(ctx)) -> A(ctx), not B(A(ctx)), always reuse parent cache context if possible.
+- With(With(With(ctx))) -> A(ctx), not C(B(A(ctx))), ditto.
+- WithNo(ctx) -> ctx, not A'(ctx), don't create new cache context if we don't have to.
+- WithNo(With(ctx)) -> A'(ctx)
+- WithNo(WithNo(With(ctx))) -> A'(ctx), not B'(A'(ctx)), don't create new cache context if we don't have to.
+- With(WithNo(With(ctx))) -> B(A'(ctx)), not A(ctx), never reuse a discard cache context.
+- WithNo(With(WithNo(With(ctx)))) -> B'(A'(ctx))
+- With(WithNo(With(WithNo(With(ctx))))) -> C(B'(A'(ctx))), so there's always only one not-discard cache context.
+*/
+
+func WithCacheContext(ctx context.Context) context.Context {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ if !c.isDiscard() {
+ // reuse parent context
+ return ctx
+ }
+ }
+ return context.WithValue(ctx, cacheContextKey, &cacheContext{
+ data: make(map[any]map[any]any),
+ created: timeNow(),
+ })
+}
+
+func WithNoCacheContext(ctx context.Context) context.Context {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ // The caller want to run long-life tasks, but the parent context is a cache context.
+ // So we should disable and clean the cache data, or it will be kept in memory for a long time.
+ c.Discard()
+ return ctx
+ }
+
+ return ctx
+}
+
+func GetContextData(ctx context.Context, tp, key any) any {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ if c.Expired() {
+ // The warning means that the cache context is misused for long-life task,
+ // it can be resolved with WithNoCacheContext(ctx).
+ log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
+ return nil
+ }
+ return c.Get(tp, key)
+ }
+ return nil
+}
+
+func SetContextData(ctx context.Context, tp, key, value any) {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ if c.Expired() {
+ // The warning means that the cache context is misused for long-life task,
+ // it can be resolved with WithNoCacheContext(ctx).
+ log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
+ return
+ }
+ c.Put(tp, key, value)
+ return
+ }
+}
+
+func RemoveContextData(ctx context.Context, tp, key any) {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ if c.Expired() {
+ // The warning means that the cache context is misused for long-life task,
+ // it can be resolved with WithNoCacheContext(ctx).
+ log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
+ return
+ }
+ c.Delete(tp, key)
+ }
+}
+
+// GetWithContextCache returns the cache value of the given key in the given context.
+func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error) {
+ v := GetContextData(ctx, cacheGroupKey, cacheTargetID)
+ if vv, ok := v.(T); ok {
+ return vv, nil
+ }
+ t, err := f()
+ if err != nil {
+ return t, err
+ }
+ SetContextData(ctx, cacheGroupKey, cacheTargetID, t)
+ return t, nil
+}
diff --git a/modules/cache/context_test.go b/modules/cache/context_test.go
new file mode 100644
index 0000000..072c394
--- /dev/null
+++ b/modules/cache/context_test.go
@@ -0,0 +1,79 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithCacheContext(t *testing.T) {
+ ctx := WithCacheContext(context.Background())
+
+ v := GetContextData(ctx, "empty_field", "my_config1")
+ assert.Nil(t, v)
+
+ const field = "system_setting"
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.NotNil(t, v)
+ assert.EqualValues(t, 1, v.(int))
+
+ RemoveContextData(ctx, field, "my_config1")
+ RemoveContextData(ctx, field, "my_config2") // remove a non-exist key
+
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+
+ vInt, err := GetWithContextCache(ctx, field, "my_config1", func() (int, error) {
+ return 1, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, vInt)
+
+ v = GetContextData(ctx, field, "my_config1")
+ assert.EqualValues(t, 1, v)
+
+ now := timeNow
+ defer func() {
+ timeNow = now
+ }()
+ timeNow = func() time.Time {
+ return now().Add(5 * time.Minute)
+ }
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+}
+
+func TestWithNoCacheContext(t *testing.T) {
+ ctx := context.Background()
+
+ const field = "system_setting"
+
+ v := GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v) // still no cache
+
+ ctx = WithCacheContext(ctx)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.NotNil(t, v)
+
+ ctx = WithNoCacheContext(ctx)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v) // still no cache
+}
diff --git a/modules/charset/ambiguous.go b/modules/charset/ambiguous.go
new file mode 100644
index 0000000..96e0561
--- /dev/null
+++ b/modules/charset/ambiguous.go
@@ -0,0 +1,59 @@
+// This file is generated by modules/charset/ambiguous/generate.go DO NOT EDIT
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "sort"
+ "strings"
+ "unicode"
+
+ "code.gitea.io/gitea/modules/translation"
+)
+
+// AmbiguousTablesForLocale provides the table of ambiguous characters for this locale.
+func AmbiguousTablesForLocale(locale translation.Locale) []*AmbiguousTable {
+ key := locale.Language()
+ var table *AmbiguousTable
+ var ok bool
+ for len(key) > 0 {
+ if table, ok = AmbiguousCharacters[key]; ok {
+ break
+ }
+ idx := strings.LastIndexAny(key, "-_")
+ if idx < 0 {
+ key = ""
+ } else {
+ key = key[:idx]
+ }
+ }
+ if table == nil && (locale.Language() == "zh-CN" || locale.Language() == "zh_CN") {
+ table = AmbiguousCharacters["zh-hans"]
+ }
+ if table == nil && strings.HasPrefix(locale.Language(), "zh") {
+ table = AmbiguousCharacters["zh-hant"]
+ }
+ if table == nil {
+ table = AmbiguousCharacters["_default"]
+ }
+
+ return []*AmbiguousTable{
+ table,
+ AmbiguousCharacters["_common"],
+ }
+}
+
+func isAmbiguous(r rune, confusableTo *rune, tables ...*AmbiguousTable) bool {
+ for _, table := range tables {
+ if !unicode.Is(table.RangeTable, r) {
+ continue
+ }
+ i := sort.Search(len(table.Confusable), func(i int) bool {
+ return table.Confusable[i] >= r
+ })
+ (*confusableTo) = table.With[i]
+ return true
+ }
+ return false
+}
diff --git a/modules/charset/ambiguous/ambiguous.json b/modules/charset/ambiguous/ambiguous.json
new file mode 100644
index 0000000..d0f69f6
--- /dev/null
+++ b/modules/charset/ambiguous/ambiguous.json
@@ -0,0 +1 @@
+"{\"_common\":[8232,32,8233,32,5760,32,8192,32,8193,32,8194,32,8195,32,8196,32,8197,32,8198,32,8200,32,8201,32,8202,32,8287,32,8199,32,8239,32,2042,95,65101,95,65102,95,65103,95,8208,45,8209,45,8210,45,65112,45,1748,45,8259,45,727,45,8722,45,10134,45,11450,45,1549,44,1643,44,8218,44,184,44,42233,44,894,59,2307,58,2691,58,1417,58,1795,58,1796,58,5868,58,65072,58,6147,58,6153,58,8282,58,1475,58,760,58,42889,58,8758,58,720,58,42237,58,451,33,11601,33,660,63,577,63,2429,63,5038,63,42731,63,119149,46,8228,46,1793,46,1794,46,42510,46,68176,46,1632,46,1776,46,42232,46,1373,96,65287,96,8219,96,8242,96,1370,96,1523,96,8175,96,65344,96,900,96,8189,96,8125,96,8127,96,8190,96,697,96,884,96,712,96,714,96,715,96,756,96,699,96,701,96,700,96,702,96,42892,96,1497,96,2036,96,2037,96,5194,96,5836,96,94033,96,94034,96,65339,91,10088,40,10098,40,12308,40,64830,40,65341,93,10089,41,10099,41,12309,41,64831,41,10100,123,119060,123,10101,125,65342,94,8270,42,1645,42,8727,42,66335,42,5941,47,8257,47,8725,47,8260,47,9585,47,10187,47,10744,47,119354,47,12755,47,12339,47,11462,47,20031,47,12035,47,65340,92,65128,92,8726,92,10189,92,10741,92,10745,92,119311,92,119355,92,12756,92,20022,92,12034,92,42872,38,708,94,710,94,5869,43,10133,43,66203,43,8249,60,10094,60,706,60,119350,60,5176,60,5810,60,5120,61,11840,61,12448,61,42239,61,8250,62,10095,62,707,62,119351,62,5171,62,94015,62,8275,126,732,126,8128,126,8764,126,65372,124,65293,45,120784,50,120794,50,120804,50,120814,50,120824,50,130034,50,42842,50,423,50,1000,50,42564,50,5311,50,42735,50,119302,51,120785,51,120795,51,120805,51,120815,51,120825,51,130035,51,42923,51,540,51,439,51,42858,51,11468,51,1248,51,94011,51,71882,51,120786,52,120796,52,120806,52,120816,52,120826,52,130036,52,5070,52,71855,52,120787,53,120797,53,120807,53,120817,53,120827,53,130037,53,444,53,71867,53,120788,54,120798,54,120808,54,120818,54,120828,54,130038,54,11474,54,5102,54,71893,54,119314,55,120789,55,120799,55,120809,55,120819,55,120829,55,130039,55,66770,55,71878,55,2819,56,2538,56,2666,56,125131,56,120790,56,120800,56,120810,56,120820,56,120830,56,130040,56,547,56,546,56,66330,56,2663,57,2920,57,2541,57,3437,57,120791,57,120801,57,120811,57,120821,57,120831,57,130041,57,42862,57,11466,57,71884,57,71852,57,71894,57,9082,97,65345,97,119834,97,119886,97,119938,97,119990,97,120042,97,120094,97,120146,97,120198,97,120250,97,120302,97,120354,97,120406,97,120458,97,593,97,945,97,120514,97,120572,97,120630,97,120688,97,120746,97,65313,65,119808,65,119860,65,119912,65,119964,65,120016,65,120068,65,120120,65,120172,65,120224,65,120276,65,120328,65,120380,65,120432,65,913,65,120488,65,120546,65,120604,65,120662,65,120720,65,5034,65,5573,65,42222,65,94016,65,66208,65,119835,98,119887,98,119939,98,119991,98,120043,98,120095,98,120147,98,120199,98,120251,98,120303,98,120355,98,120407,98,120459,98,388,98,5071,98,5234,98,5551,98,65314,66,8492,66,119809,66,119861,66,119913,66,120017,66,120069,66,120121,66,120173,66,120225,66,120277,66,120329,66,120381,66,120433,66,42932,66,914,66,120489,66,120547,66,120605,66,120663,66,120721,66,5108,66,5623,66,42192,66,66178,66,66209,66,66305,66,65347,99,8573,99,119836,99,119888,99,119940,99,119992,99,120044,99,120096,99,120148,99,120200,99,120252,99,120304,99,120356,99,120408,99,120460,99,7428,99,1010,99,11429,99,43951,99,66621,99,128844,67,71922,67,71913,67,65315,67,8557,67,8450,67,8493,67,119810,67,119862,67,119914,67,119966,67,120018,67,120174,67,120226,67,120278,67,120330,67,120382,67,120434,67,1017,67,11428,67,5087,67,42202,67,66210,67,66306,67,66581,67,66844,67,8574,100,8518,100,119837,100,119889,100,119941,100,119993,100,120045,100,120097,100,120149,100,120201,100,120253,100,120305,100,120357,100,120409,100,120461,100,1281,100,5095,100,5231,100,42194,100,8558,68,8517,68,119811,68,119863,68,119915,68,119967,68,120019,68,120071,68,120123,68,120175,68,120227,68,120279,68,120331,68,120383,68,120435,68,5024,68,5598,68,5610,68,42195,68,8494,101,65349,101,8495,101,8519,101,119838,101,119890,101,119942,101,120046,101,120098,101,120150,101,120202,101,120254,101,120306,101,120358,101,120410,101,120462,101,43826,101,1213,101,8959,69,65317,69,8496,69,119812,69,119864,69,119916,69,120020,69,120072,69,120124,69,120176,69,120228,69,120280,69,120332,69,120384,69,120436,69,917,69,120492,69,120550,69,120608,69,120666,69,120724,69,11577,69,5036,69,42224,69,71846,69,71854,69,66182,69,119839,102,119891,102,119943,102,119995,102,120047,102,120099,102,120151,102,120203,102,120255,102,120307,102,120359,102,120411,102,120463,102,43829,102,42905,102,383,102,7837,102,1412,102,119315,70,8497,70,119813,70,119865,70,119917,70,120021,70,120073,70,120125,70,120177,70,120229,70,120281,70,120333,70,120385,70,120437,70,42904,70,988,70,120778,70,5556,70,42205,70,71874,70,71842,70,66183,70,66213,70,66853,70,65351,103,8458,103,119840,103,119892,103,119944,103,120048,103,120100,103,120152,103,120204,103,120256,103,120308,103,120360,103,120412,103,120464,103,609,103,7555,103,397,103,1409,103,119814,71,119866,71,119918,71,119970,71,120022,71,120074,71,120126,71,120178,71,120230,71,120282,71,120334,71,120386,71,120438,71,1292,71,5056,71,5107,71,42198,71,65352,104,8462,104,119841,104,119945,104,119997,104,120049,104,120101,104,120153,104,120205,104,120257,104,120309,104,120361,104,120413,104,120465,104,1211,104,1392,104,5058,104,65320,72,8459,72,8460,72,8461,72,119815,72,119867,72,119919,72,120023,72,120179,72,120231,72,120283,72,120335,72,120387,72,120439,72,919,72,120494,72,120552,72,120610,72,120668,72,120726,72,11406,72,5051,72,5500,72,42215,72,66255,72,731,105,9075,105,65353,105,8560,105,8505,105,8520,105,119842,105,119894,105,119946,105,119998,105,120050,105,120102,105,120154,105,120206,105,120258,105,120310,105,120362,105,120414,105,120466,105,120484,105,618,105,617,105,953,105,8126,105,890,105,120522,105,120580,105,120638,105,120696,105,120754,105,1110,105,42567,105,1231,105,43893,105,5029,105,71875,105,65354,106,8521,106,119843,106,119895,106,119947,106,119999,106,120051,106,120103,106,120155,106,120207,106,120259,106,120311,106,120363,106,120415,106,120467,106,1011,106,1112,106,65322,74,119817,74,119869,74,119921,74,119973,74,120025,74,120077,74,120129,74,120181,74,120233,74,120285,74,120337,74,120389,74,120441,74,42930,74,895,74,1032,74,5035,74,5261,74,42201,74,119844,107,119896,107,119948,107,120000,107,120052,107,120104,107,120156,107,120208,107,120260,107,120312,107,120364,107,120416,107,120468,107,8490,75,65323,75,119818,75,119870,75,119922,75,119974,75,120026,75,120078,75,120130,75,120182,75,120234,75,120286,75,120338,75,120390,75,120442,75,922,75,120497,75,120555,75,120613,75,120671,75,120729,75,11412,75,5094,75,5845,75,42199,75,66840,75,1472,108,8739,73,9213,73,65512,73,1633,108,1777,73,66336,108,125127,108,120783,73,120793,73,120803,73,120813,73,120823,73,130033,73,65321,73,8544,73,8464,73,8465,73,119816,73,119868,73,119920,73,120024,73,120128,73,120180,73,120232,73,120284,73,120336,73,120388,73,120440,73,65356,108,8572,73,8467,108,119845,108,119897,108,119949,108,120001,108,120053,108,120105,73,120157,73,120209,73,120261,73,120313,73,120365,73,120417,73,120469,73,448,73,120496,73,120554,73,120612,73,120670,73,120728,73,11410,73,1030,73,1216,73,1493,108,1503,108,1575,108,126464,108,126592,108,65166,108,65165,108,1994,108,11599,73,5825,73,42226,73,93992,73,66186,124,66313,124,119338,76,8556,76,8466,76,119819,76,119871,76,119923,76,120027,76,120079,76,120131,76,120183,76,120235,76,120287,76,120339,76,120391,76,120443,76,11472,76,5086,76,5290,76,42209,76,93974,76,71843,76,71858,76,66587,76,66854,76,65325,77,8559,77,8499,77,119820,77,119872,77,119924,77,120028,77,120080,77,120132,77,120184,77,120236,77,120288,77,120340,77,120392,77,120444,77,924,77,120499,77,120557,77,120615,77,120673,77,120731,77,1018,77,11416,77,5047,77,5616,77,5846,77,42207,77,66224,77,66321,77,119847,110,119899,110,119951,110,120003,110,120055,110,120107,110,120159,110,120211,110,120263,110,120315,110,120367,110,120419,110,120471,110,1400,110,1404,110,65326,78,8469,78,119821,78,119873,78,119925,78,119977,78,120029,78,120081,78,120185,78,120237,78,120289,78,120341,78,120393,78,120445,78,925,78,120500,78,120558,78,120616,78,120674,78,120732,78,11418,78,42208,78,66835,78,3074,111,3202,111,3330,111,3458,111,2406,111,2662,111,2790,111,3046,111,3174,111,3302,111,3430,111,3664,111,3792,111,4160,111,1637,111,1781,111,65359,111,8500,111,119848,111,119900,111,119952,111,120056,111,120108,111,120160,111,120212,111,120264,111,120316,111,120368,111,120420,111,120472,111,7439,111,7441,111,43837,111,959,111,120528,111,120586,111,120644,111,120702,111,120760,111,963,111,120532,111,120590,111,120648,111,120706,111,120764,111,11423,111,4351,111,1413,111,1505,111,1607,111,126500,111,126564,111,126596,111,65259,111,65260,111,65258,111,65257,111,1726,111,64428,111,64429,111,64427,111,64426,111,1729,111,64424,111,64425,111,64423,111,64422,111,1749,111,3360,111,4125,111,66794,111,71880,111,71895,111,66604,111,1984,79,2534,79,2918,79,12295,79,70864,79,71904,79,120782,79,120792,79,120802,79,120812,79,120822,79,130032,79,65327,79,119822,79,119874,79,119926,79,119978,79,120030,79,120082,79,120134,79,120186,79,120238,79,120290,79,120342,79,120394,79,120446,79,927,79,120502,79,120560,79,120618,79,120676,79,120734,79,11422,79,1365,79,11604,79,4816,79,2848,79,66754,79,42227,79,71861,79,66194,79,66219,79,66564,79,66838,79,9076,112,65360,112,119849,112,119901,112,119953,112,120005,112,120057,112,120109,112,120161,112,120213,112,120265,112,120317,112,120369,112,120421,112,120473,112,961,112,120530,112,120544,112,120588,112,120602,112,120646,112,120660,112,120704,112,120718,112,120762,112,120776,112,11427,112,65328,80,8473,80,119823,80,119875,80,119927,80,119979,80,120031,80,120083,80,120187,80,120239,80,120291,80,120343,80,120395,80,120447,80,929,80,120504,80,120562,80,120620,80,120678,80,120736,80,11426,80,5090,80,5229,80,42193,80,66197,80,119850,113,119902,113,119954,113,120006,113,120058,113,120110,113,120162,113,120214,113,120266,113,120318,113,120370,113,120422,113,120474,113,1307,113,1379,113,1382,113,8474,81,119824,81,119876,81,119928,81,119980,81,120032,81,120084,81,120188,81,120240,81,120292,81,120344,81,120396,81,120448,81,11605,81,119851,114,119903,114,119955,114,120007,114,120059,114,120111,114,120163,114,120215,114,120267,114,120319,114,120371,114,120423,114,120475,114,43847,114,43848,114,7462,114,11397,114,43905,114,119318,82,8475,82,8476,82,8477,82,119825,82,119877,82,119929,82,120033,82,120189,82,120241,82,120293,82,120345,82,120397,82,120449,82,422,82,5025,82,5074,82,66740,82,5511,82,42211,82,94005,82,65363,115,119852,115,119904,115,119956,115,120008,115,120060,115,120112,115,120164,115,120216,115,120268,115,120320,115,120372,115,120424,115,120476,115,42801,115,445,115,1109,115,43946,115,71873,115,66632,115,65331,83,119826,83,119878,83,119930,83,119982,83,120034,83,120086,83,120138,83,120190,83,120242,83,120294,83,120346,83,120398,83,120450,83,1029,83,1359,83,5077,83,5082,83,42210,83,94010,83,66198,83,66592,83,119853,116,119905,116,119957,116,120009,116,120061,116,120113,116,120165,116,120217,116,120269,116,120321,116,120373,116,120425,116,120477,116,8868,84,10201,84,128872,84,65332,84,119827,84,119879,84,119931,84,119983,84,120035,84,120087,84,120139,84,120191,84,120243,84,120295,84,120347,84,120399,84,120451,84,932,84,120507,84,120565,84,120623,84,120681,84,120739,84,11430,84,5026,84,42196,84,93962,84,71868,84,66199,84,66225,84,66325,84,119854,117,119906,117,119958,117,120010,117,120062,117,120114,117,120166,117,120218,117,120270,117,120322,117,120374,117,120426,117,120478,117,42911,117,7452,117,43854,117,43858,117,651,117,965,117,120534,117,120592,117,120650,117,120708,117,120766,117,1405,117,66806,117,71896,117,8746,85,8899,85,119828,85,119880,85,119932,85,119984,85,120036,85,120088,85,120140,85,120192,85,120244,85,120296,85,120348,85,120400,85,120452,85,1357,85,4608,85,66766,85,5196,85,42228,85,94018,85,71864,85,8744,118,8897,118,65366,118,8564,118,119855,118,119907,118,119959,118,120011,118,120063,118,120115,118,120167,118,120219,118,120271,118,120323,118,120375,118,120427,118,120479,118,7456,118,957,118,120526,118,120584,118,120642,118,120700,118,120758,118,1141,118,1496,118,71430,118,43945,118,71872,118,119309,86,1639,86,1783,86,8548,86,119829,86,119881,86,119933,86,119985,86,120037,86,120089,86,120141,86,120193,86,120245,86,120297,86,120349,86,120401,86,120453,86,1140,86,11576,86,5081,86,5167,86,42719,86,42214,86,93960,86,71840,86,66845,86,623,119,119856,119,119908,119,119960,119,120012,119,120064,119,120116,119,120168,119,120220,119,120272,119,120324,119,120376,119,120428,119,120480,119,7457,119,1121,119,1309,119,1377,119,71434,119,71438,119,71439,119,43907,119,71919,87,71910,87,119830,87,119882,87,119934,87,119986,87,120038,87,120090,87,120142,87,120194,87,120246,87,120298,87,120350,87,120402,87,120454,87,1308,87,5043,87,5076,87,42218,87,5742,120,10539,120,10540,120,10799,120,65368,120,8569,120,119857,120,119909,120,119961,120,120013,120,120065,120,120117,120,120169,120,120221,120,120273,120,120325,120,120377,120,120429,120,120481,120,5441,120,5501,120,5741,88,9587,88,66338,88,71916,88,65336,88,8553,88,119831,88,119883,88,119935,88,119987,88,120039,88,120091,88,120143,88,120195,88,120247,88,120299,88,120351,88,120403,88,120455,88,42931,88,935,88,120510,88,120568,88,120626,88,120684,88,120742,88,11436,88,11613,88,5815,88,42219,88,66192,88,66228,88,66327,88,66855,88,611,121,7564,121,65369,121,119858,121,119910,121,119962,121,120014,121,120066,121,120118,121,120170,121,120222,121,120274,121,120326,121,120378,121,120430,121,120482,121,655,121,7935,121,43866,121,947,121,8509,121,120516,121,120574,121,120632,121,120690,121,120748,121,1199,121,4327,121,71900,121,65337,89,119832,89,119884,89,119936,89,119988,89,120040,89,120092,89,120144,89,120196,89,120248,89,120300,89,120352,89,120404,89,120456,89,933,89,978,89,120508,89,120566,89,120624,89,120682,89,120740,89,11432,89,1198,89,5033,89,5053,89,42220,89,94019,89,71844,89,66226,89,119859,122,119911,122,119963,122,120015,122,120067,122,120119,122,120171,122,120223,122,120275,122,120327,122,120379,122,120431,122,120483,122,7458,122,43923,122,71876,122,66293,90,71909,90,65338,90,8484,90,8488,90,119833,90,119885,90,119937,90,119989,90,120041,90,120197,90,120249,90,120301,90,120353,90,120405,90,120457,90,918,90,120493,90,120551,90,120609,90,120667,90,120725,90,5059,90,42204,90,71849,90,65282,34,65284,36,65285,37,65286,38,65290,42,65291,43,65294,46,65295,47,65296,48,65297,49,65298,50,65299,51,65300,52,65301,53,65302,54,65303,55,65304,56,65305,57,65308,60,65309,61,65310,62,65312,64,65316,68,65318,70,65319,71,65324,76,65329,81,65330,82,65333,85,65334,86,65335,87,65343,95,65346,98,65348,100,65350,102,65355,107,65357,109,65358,110,65361,113,65362,114,65364,116,65365,117,65367,119,65370,122,65371,123,65373,125],\"_default\":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"cs\":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"de\":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"es\":[8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"fr\":[65374,126,65306,58,65281,33,8216,96,8245,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"it\":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"ja\":[8211,45,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65292,44,65307,59],\"ko\":[8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"pl\":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"pt-BR\":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"qps-ploc\":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"ru\":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,305,105,921,73,1009,112,215,120,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"tr\":[160,32,8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],\"zh-hans\":[65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65288,40,65289,41],\"zh-hant\":[8211,45,65374,126,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65307,59]}" \ No newline at end of file
diff --git a/modules/charset/ambiguous/generate.go b/modules/charset/ambiguous/generate.go
new file mode 100644
index 0000000..e3fda5b
--- /dev/null
+++ b/modules/charset/ambiguous/generate.go
@@ -0,0 +1,188 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "go/format"
+ "os"
+ "sort"
+ "text/template"
+ "unicode"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "golang.org/x/text/unicode/rangetable"
+)
+
+// ambiguous.json provides a one to one mapping of ambiguous characters to other characters
+// See https://github.com/hediet/vscode-unicode-data/blob/main/out/ambiguous.json
+
+type AmbiguousTable struct {
+ Confusable []rune
+ With []rune
+ Locale string
+ RangeTable *unicode.RangeTable
+}
+
+type RunePair struct {
+ Confusable rune
+ With rune
+}
+
+var verbose bool
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, `%s: Generate AmbiguousCharacter
+
+Usage: %[1]s [-v] [-o output.go] ambiguous.json
+`, os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ output := ""
+ flag.BoolVar(&verbose, "v", false, "verbose output")
+ flag.StringVar(&output, "o", "ambiguous_gen.go", "file to output to")
+ flag.Parse()
+ input := flag.Arg(0)
+ if input == "" {
+ input = "ambiguous.json"
+ }
+
+ bs, err := os.ReadFile(input)
+ if err != nil {
+ fatalf("Unable to read: %s Err: %v", input, err)
+ }
+
+ var unwrapped string
+ if err := json.Unmarshal(bs, &unwrapped); err != nil {
+ fatalf("Unable to unwrap content in: %s Err: %v", input, err)
+ }
+
+ fromJSON := map[string][]uint32{}
+ if err := json.Unmarshal([]byte(unwrapped), &fromJSON); err != nil {
+ fatalf("Unable to unmarshal content in: %s Err: %v", input, err)
+ }
+
+ tables := make([]*AmbiguousTable, 0, len(fromJSON))
+ for locale, chars := range fromJSON {
+ table := &AmbiguousTable{Locale: locale}
+ table.Confusable = make([]rune, 0, len(chars)/2)
+ table.With = make([]rune, 0, len(chars)/2)
+ pairs := make([]RunePair, len(chars)/2)
+ for i := 0; i < len(chars); i += 2 {
+ pairs[i/2].Confusable, pairs[i/2].With = rune(chars[i]), rune(chars[i+1])
+ }
+ sort.Slice(pairs, func(i, j int) bool {
+ return pairs[i].Confusable < pairs[j].Confusable
+ })
+ for _, pair := range pairs {
+ table.Confusable = append(table.Confusable, pair.Confusable)
+ table.With = append(table.With, pair.With)
+ }
+ table.RangeTable = rangetable.New(table.Confusable...)
+ tables = append(tables, table)
+ }
+ sort.Slice(tables, func(i, j int) bool {
+ return tables[i].Locale < tables[j].Locale
+ })
+ data := map[string]any{
+ "Tables": tables,
+ }
+
+ if err := runTemplate(generatorTemplate, output, &data); err != nil {
+ fatalf("Unable to run template: %v", err)
+ }
+}
+
+func runTemplate(t *template.Template, filename string, data any) error {
+ buf := bytes.NewBuffer(nil)
+ if err := t.Execute(buf, data); err != nil {
+ return fmt.Errorf("unable to execute template: %w", err)
+ }
+ bs, err := format.Source(buf.Bytes())
+ if err != nil {
+ verbosef("Bad source:\n%s", buf.String())
+ return fmt.Errorf("unable to format source: %w", err)
+ }
+
+ old, err := os.ReadFile(filename)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to read old file %s because %w", filename, err)
+ } else if err == nil {
+ if bytes.Equal(bs, old) {
+ // files are the same don't rewrite it.
+ return nil
+ }
+ }
+
+ file, err := os.Create(filename)
+ if err != nil {
+ return fmt.Errorf("failed to create file %s because %w", filename, err)
+ }
+ defer file.Close()
+ _, err = file.Write(bs)
+ if err != nil {
+ return fmt.Errorf("unable to write generated source: %w", err)
+ }
+ return nil
+}
+
+var generatorTemplate = template.Must(template.New("ambiguousTemplate").Parse(`// This file is generated by modules/charset/ambiguous/generate.go DO NOT EDIT
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+
+package charset
+
+import "unicode"
+
+// This file is generated from https://github.com/hediet/vscode-unicode-data/blob/main/out/ambiguous.json
+
+// AmbiguousTable matches a confusable rune with its partner for the Locale
+type AmbiguousTable struct {
+ Confusable []rune
+ With []rune
+ Locale string
+ RangeTable *unicode.RangeTable
+}
+
+// AmbiguousCharacters provides a map by locale name to the confusable characters in that locale
+var AmbiguousCharacters = map[string]*AmbiguousTable{
+ {{range .Tables}}{{printf "%q:" .Locale}} {
+ Confusable: []rune{ {{range .Confusable}}{{.}},{{end}} },
+ With: []rune{ {{range .With}}{{.}},{{end}} },
+ Locale: {{printf "%q" .Locale}},
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {{range .RangeTable.R16 }} {Lo:{{.Lo}}, Hi:{{.Hi}}, Stride: {{.Stride}}},
+ {{end}} },
+ R32: []unicode.Range32{
+ {{range .RangeTable.R32}} {Lo:{{.Lo}}, Hi:{{.Hi}}, Stride: {{.Stride}}},
+ {{end}} },
+ LatinOffset: {{.RangeTable.LatinOffset}},
+ },
+ },
+ {{end}}
+}
+
+`))
+
+func logf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
+
+func verbosef(format string, args ...any) {
+ if verbose {
+ logf(format, args...)
+ }
+}
+
+func fatalf(format string, args ...any) {
+ logf("fatal: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/modules/charset/ambiguous_gen.go b/modules/charset/ambiguous_gen.go
new file mode 100644
index 0000000..c88ffd5
--- /dev/null
+++ b/modules/charset/ambiguous_gen.go
@@ -0,0 +1,836 @@
+// This file is generated by modules/charset/ambiguous/generate.go DO NOT EDIT
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import "unicode"
+
+// This file is generated from https://github.com/hediet/vscode-unicode-data/blob/main/out/ambiguous.json
+
+// AmbiguousTable matches a confusable rune with its partner for the Locale
+type AmbiguousTable struct {
+ Confusable []rune
+ With []rune
+ Locale string
+ RangeTable *unicode.RangeTable
+}
+
+// AmbiguousCharacters provides a map by locale name to the confusable characters in that locale
+var AmbiguousCharacters = map[string]*AmbiguousTable{
+ "_common": {
+ Confusable: []rune{184, 383, 388, 397, 422, 423, 439, 444, 445, 448, 451, 540, 546, 547, 577, 593, 609, 611, 617, 618, 623, 651, 655, 660, 697, 699, 700, 701, 702, 706, 707, 708, 710, 712, 714, 715, 720, 727, 731, 732, 756, 760, 884, 890, 894, 895, 900, 913, 914, 917, 918, 919, 922, 924, 925, 927, 929, 932, 933, 935, 945, 947, 953, 957, 959, 961, 963, 965, 978, 988, 1000, 1010, 1011, 1017, 1018, 1029, 1030, 1032, 1109, 1110, 1112, 1121, 1140, 1141, 1198, 1199, 1211, 1213, 1216, 1231, 1248, 1281, 1292, 1307, 1308, 1309, 1357, 1359, 1365, 1370, 1373, 1377, 1379, 1382, 1392, 1400, 1404, 1405, 1409, 1412, 1413, 1417, 1472, 1475, 1493, 1496, 1497, 1503, 1505, 1523, 1549, 1575, 1607, 1632, 1633, 1637, 1639, 1643, 1645, 1726, 1729, 1748, 1749, 1776, 1777, 1781, 1783, 1793, 1794, 1795, 1796, 1984, 1994, 2036, 2037, 2042, 2307, 2406, 2429, 2534, 2538, 2541, 2662, 2663, 2666, 2691, 2790, 2819, 2848, 2918, 2920, 3046, 3074, 3174, 3202, 3302, 3330, 3360, 3430, 3437, 3458, 3664, 3792, 4125, 4160, 4327, 4351, 4608, 4816, 5024, 5025, 5026, 5029, 5033, 5034, 5035, 5036, 5038, 5043, 5047, 5051, 5053, 5056, 5058, 5059, 5070, 5071, 5074, 5076, 5077, 5081, 5082, 5086, 5087, 5090, 5094, 5095, 5102, 5107, 5108, 5120, 5167, 5171, 5176, 5194, 5196, 5229, 5231, 5234, 5261, 5290, 5311, 5441, 5500, 5501, 5511, 5551, 5556, 5573, 5598, 5610, 5616, 5623, 5741, 5742, 5760, 5810, 5815, 5825, 5836, 5845, 5846, 5868, 5869, 5941, 6147, 6153, 7428, 7439, 7441, 7452, 7456, 7457, 7458, 7462, 7555, 7564, 7837, 7935, 8125, 8126, 8127, 8128, 8175, 8189, 8190, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8208, 8209, 8210, 8218, 8219, 8228, 8232, 8233, 8239, 8242, 8249, 8250, 8257, 8259, 8260, 8270, 8275, 8282, 8287, 8450, 8458, 8459, 8460, 8461, 8462, 8464, 8465, 8466, 8467, 8469, 8473, 8474, 8475, 8476, 8477, 8484, 8488, 8490, 8492, 8493, 8494, 8495, 8496, 8497, 8499, 8500, 8505, 8509, 8517, 8518, 8519, 8520, 8521, 8544, 8548, 8553, 8556, 8557, 8558, 8559, 8560, 8564, 8569, 8572, 8573, 8574, 8722, 8725, 8726, 8727, 8739, 8744, 8746, 8758, 8764, 8868, 8897, 8899, 8959, 9075, 9076, 9082, 9213, 9585, 9587, 10088, 10089, 10094, 10095, 10098, 10099, 10100, 10101, 10133, 10134, 10187, 10189, 10201, 10539, 10540, 10741, 10744, 10745, 10799, 11397, 11406, 11410, 11412, 11416, 11418, 11422, 11423, 11426, 11427, 11428, 11429, 11430, 11432, 11436, 11450, 11462, 11466, 11468, 11472, 11474, 11576, 11577, 11599, 11601, 11604, 11605, 11613, 11840, 12034, 12035, 12295, 12308, 12309, 12339, 12448, 12755, 12756, 20022, 20031, 42192, 42193, 42194, 42195, 42196, 42198, 42199, 42201, 42202, 42204, 42205, 42207, 42208, 42209, 42210, 42211, 42214, 42215, 42218, 42219, 42220, 42222, 42224, 42226, 42227, 42228, 42232, 42233, 42237, 42239, 42510, 42564, 42567, 42719, 42731, 42735, 42801, 42842, 42858, 42862, 42872, 42889, 42892, 42904, 42905, 42911, 42923, 42930, 42931, 42932, 43826, 43829, 43837, 43847, 43848, 43854, 43858, 43866, 43893, 43905, 43907, 43923, 43945, 43946, 43951, 64422, 64423, 64424, 64425, 64426, 64427, 64428, 64429, 64830, 64831, 65072, 65101, 65102, 65103, 65112, 65128, 65165, 65166, 65257, 65258, 65259, 65260, 65282, 65284, 65285, 65286, 65287, 65290, 65291, 65293, 65294, 65295, 65296, 65297, 65298, 65299, 65300, 65301, 65302, 65303, 65304, 65305, 65308, 65309, 65310, 65312, 65313, 65314, 65315, 65316, 65317, 65318, 65319, 65320, 65321, 65322, 65323, 65324, 65325, 65326, 65327, 65328, 65329, 65330, 65331, 65332, 65333, 65334, 65335, 65336, 65337, 65338, 65339, 65340, 65341, 65342, 65343, 65344, 65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, 65354, 65355, 65356, 65357, 65358, 65359, 65360, 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, 65370, 65371, 65372, 65373, 65512, 66178, 66182, 66183, 66186, 66192, 66194, 66197, 66198, 66199, 66203, 66208, 66209, 66210, 66213, 66219, 66224, 66225, 66226, 66228, 66255, 66293, 66305, 66306, 66313, 66321, 66325, 66327, 66330, 66335, 66336, 66338, 66564, 66581, 66587, 66592, 66604, 66621, 66632, 66740, 66754, 66766, 66770, 66794, 66806, 66835, 66838, 66840, 66844, 66845, 66853, 66854, 66855, 68176, 70864, 71430, 71434, 71438, 71439, 71840, 71842, 71843, 71844, 71846, 71849, 71852, 71854, 71855, 71858, 71861, 71864, 71867, 71868, 71872, 71873, 71874, 71875, 71876, 71878, 71880, 71882, 71884, 71893, 71894, 71895, 71896, 71900, 71904, 71909, 71910, 71913, 71916, 71919, 71922, 93960, 93962, 93974, 93992, 94005, 94010, 94011, 94015, 94016, 94018, 94019, 94033, 94034, 119060, 119149, 119302, 119309, 119311, 119314, 119315, 119318, 119338, 119350, 119351, 119354, 119355, 119808, 119809, 119810, 119811, 119812, 119813, 119814, 119815, 119816, 119817, 119818, 119819, 119820, 119821, 119822, 119823, 119824, 119825, 119826, 119827, 119828, 119829, 119830, 119831, 119832, 119833, 119834, 119835, 119836, 119837, 119838, 119839, 119840, 119841, 119842, 119843, 119844, 119845, 119847, 119848, 119849, 119850, 119851, 119852, 119853, 119854, 119855, 119856, 119857, 119858, 119859, 119860, 119861, 119862, 119863, 119864, 119865, 119866, 119867, 119868, 119869, 119870, 119871, 119872, 119873, 119874, 119875, 119876, 119877, 119878, 119879, 119880, 119881, 119882, 119883, 119884, 119885, 119886, 119887, 119888, 119889, 119890, 119891, 119892, 119894, 119895, 119896, 119897, 119899, 119900, 119901, 119902, 119903, 119904, 119905, 119906, 119907, 119908, 119909, 119910, 119911, 119912, 119913, 119914, 119915, 119916, 119917, 119918, 119919, 119920, 119921, 119922, 119923, 119924, 119925, 119926, 119927, 119928, 119929, 119930, 119931, 119932, 119933, 119934, 119935, 119936, 119937, 119938, 119939, 119940, 119941, 119942, 119943, 119944, 119945, 119946, 119947, 119948, 119949, 119951, 119952, 119953, 119954, 119955, 119956, 119957, 119958, 119959, 119960, 119961, 119962, 119963, 119964, 119966, 119967, 119970, 119973, 119974, 119977, 119978, 119979, 119980, 119982, 119983, 119984, 119985, 119986, 119987, 119988, 119989, 119990, 119991, 119992, 119993, 119995, 119997, 119998, 119999, 120000, 120001, 120003, 120005, 120006, 120007, 120008, 120009, 120010, 120011, 120012, 120013, 120014, 120015, 120016, 120017, 120018, 120019, 120020, 120021, 120022, 120023, 120024, 120025, 120026, 120027, 120028, 120029, 120030, 120031, 120032, 120033, 120034, 120035, 120036, 120037, 120038, 120039, 120040, 120041, 120042, 120043, 120044, 120045, 120046, 120047, 120048, 120049, 120050, 120051, 120052, 120053, 120055, 120056, 120057, 120058, 120059, 120060, 120061, 120062, 120063, 120064, 120065, 120066, 120067, 120068, 120069, 120071, 120072, 120073, 120074, 120077, 120078, 120079, 120080, 120081, 120082, 120083, 120084, 120086, 120087, 120088, 120089, 120090, 120091, 120092, 120094, 120095, 120096, 120097, 120098, 120099, 120100, 120101, 120102, 120103, 120104, 120105, 120107, 120108, 120109, 120110, 120111, 120112, 120113, 120114, 120115, 120116, 120117, 120118, 120119, 120120, 120121, 120123, 120124, 120125, 120126, 120128, 120129, 120130, 120131, 120132, 120134, 120138, 120139, 120140, 120141, 120142, 120143, 120144, 120146, 120147, 120148, 120149, 120150, 120151, 120152, 120153, 120154, 120155, 120156, 120157, 120159, 120160, 120161, 120162, 120163, 120164, 120165, 120166, 120167, 120168, 120169, 120170, 120171, 120172, 120173, 120174, 120175, 120176, 120177, 120178, 120179, 120180, 120181, 120182, 120183, 120184, 120185, 120186, 120187, 120188, 120189, 120190, 120191, 120192, 120193, 120194, 120195, 120196, 120197, 120198, 120199, 120200, 120201, 120202, 120203, 120204, 120205, 120206, 120207, 120208, 120209, 120211, 120212, 120213, 120214, 120215, 120216, 120217, 120218, 120219, 120220, 120221, 120222, 120223, 120224, 120225, 120226, 120227, 120228, 120229, 120230, 120231, 120232, 120233, 120234, 120235, 120236, 120237, 120238, 120239, 120240, 120241, 120242, 120243, 120244, 120245, 120246, 120247, 120248, 120249, 120250, 120251, 120252, 120253, 120254, 120255, 120256, 120257, 120258, 120259, 120260, 120261, 120263, 120264, 120265, 120266, 120267, 120268, 120269, 120270, 120271, 120272, 120273, 120274, 120275, 120276, 120277, 120278, 120279, 120280, 120281, 120282, 120283, 120284, 120285, 120286, 120287, 120288, 120289, 120290, 120291, 120292, 120293, 120294, 120295, 120296, 120297, 120298, 120299, 120300, 120301, 120302, 120303, 120304, 120305, 120306, 120307, 120308, 120309, 120310, 120311, 120312, 120313, 120315, 120316, 120317, 120318, 120319, 120320, 120321, 120322, 120323, 120324, 120325, 120326, 120327, 120328, 120329, 120330, 120331, 120332, 120333, 120334, 120335, 120336, 120337, 120338, 120339, 120340, 120341, 120342, 120343, 120344, 120345, 120346, 120347, 120348, 120349, 120350, 120351, 120352, 120353, 120354, 120355, 120356, 120357, 120358, 120359, 120360, 120361, 120362, 120363, 120364, 120365, 120367, 120368, 120369, 120370, 120371, 120372, 120373, 120374, 120375, 120376, 120377, 120378, 120379, 120380, 120381, 120382, 120383, 120384, 120385, 120386, 120387, 120388, 120389, 120390, 120391, 120392, 120393, 120394, 120395, 120396, 120397, 120398, 120399, 120400, 120401, 120402, 120403, 120404, 120405, 120406, 120407, 120408, 120409, 120410, 120411, 120412, 120413, 120414, 120415, 120416, 120417, 120419, 120420, 120421, 120422, 120423, 120424, 120425, 120426, 120427, 120428, 120429, 120430, 120431, 120432, 120433, 120434, 120435, 120436, 120437, 120438, 120439, 120440, 120441, 120442, 120443, 120444, 120445, 120446, 120447, 120448, 120449, 120450, 120451, 120452, 120453, 120454, 120455, 120456, 120457, 120458, 120459, 120460, 120461, 120462, 120463, 120464, 120465, 120466, 120467, 120468, 120469, 120471, 120472, 120473, 120474, 120475, 120476, 120477, 120478, 120479, 120480, 120481, 120482, 120483, 120484, 120488, 120489, 120492, 120493, 120494, 120496, 120497, 120499, 120500, 120502, 120504, 120507, 120508, 120510, 120514, 120516, 120522, 120526, 120528, 120530, 120532, 120534, 120544, 120546, 120547, 120550, 120551, 120552, 120554, 120555, 120557, 120558, 120560, 120562, 120565, 120566, 120568, 120572, 120574, 120580, 120584, 120586, 120588, 120590, 120592, 120602, 120604, 120605, 120608, 120609, 120610, 120612, 120613, 120615, 120616, 120618, 120620, 120623, 120624, 120626, 120630, 120632, 120638, 120642, 120644, 120646, 120648, 120650, 120660, 120662, 120663, 120666, 120667, 120668, 120670, 120671, 120673, 120674, 120676, 120678, 120681, 120682, 120684, 120688, 120690, 120696, 120700, 120702, 120704, 120706, 120708, 120718, 120720, 120721, 120724, 120725, 120726, 120728, 120729, 120731, 120732, 120734, 120736, 120739, 120740, 120742, 120746, 120748, 120754, 120758, 120760, 120762, 120764, 120766, 120776, 120778, 120782, 120783, 120784, 120785, 120786, 120787, 120788, 120789, 120790, 120791, 120792, 120793, 120794, 120795, 120796, 120797, 120798, 120799, 120800, 120801, 120802, 120803, 120804, 120805, 120806, 120807, 120808, 120809, 120810, 120811, 120812, 120813, 120814, 120815, 120816, 120817, 120818, 120819, 120820, 120821, 120822, 120823, 120824, 120825, 120826, 120827, 120828, 120829, 120830, 120831, 125127, 125131, 126464, 126500, 126564, 126592, 126596, 128844, 128872, 130032, 130033, 130034, 130035, 130036, 130037, 130038, 130039, 130040, 130041},
+ With: []rune{44, 102, 98, 103, 82, 50, 51, 53, 115, 73, 33, 51, 56, 56, 63, 97, 103, 121, 105, 105, 119, 117, 121, 63, 96, 96, 96, 96, 96, 60, 62, 94, 94, 96, 96, 96, 58, 45, 105, 126, 96, 58, 96, 105, 59, 74, 96, 65, 66, 69, 90, 72, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 89, 70, 50, 99, 106, 67, 77, 83, 73, 74, 115, 105, 106, 119, 86, 118, 89, 121, 104, 101, 73, 105, 51, 100, 71, 113, 87, 119, 85, 83, 79, 96, 96, 119, 113, 113, 104, 110, 110, 117, 103, 102, 111, 58, 108, 58, 108, 118, 96, 108, 111, 96, 44, 108, 111, 46, 108, 111, 86, 44, 42, 111, 111, 45, 111, 46, 73, 111, 86, 46, 46, 58, 58, 79, 108, 96, 96, 95, 58, 111, 63, 79, 56, 57, 111, 57, 56, 58, 111, 56, 79, 79, 57, 111, 111, 111, 111, 111, 111, 111, 111, 57, 111, 111, 111, 111, 111, 121, 111, 85, 79, 68, 82, 84, 105, 89, 65, 74, 69, 63, 87, 77, 72, 89, 71, 104, 90, 52, 98, 82, 87, 83, 86, 83, 76, 67, 80, 75, 100, 54, 71, 66, 61, 86, 62, 60, 96, 85, 80, 100, 98, 74, 76, 50, 120, 72, 120, 82, 98, 70, 65, 68, 68, 77, 66, 88, 120, 32, 60, 88, 73, 96, 75, 77, 58, 43, 47, 58, 58, 99, 111, 111, 117, 118, 119, 122, 114, 103, 121, 102, 121, 96, 105, 96, 126, 96, 96, 96, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 45, 45, 45, 44, 96, 46, 32, 32, 32, 96, 60, 62, 47, 45, 47, 42, 126, 58, 32, 67, 103, 72, 72, 72, 104, 73, 73, 76, 108, 78, 80, 81, 82, 82, 82, 90, 90, 75, 66, 67, 101, 101, 69, 70, 77, 111, 105, 121, 68, 100, 101, 105, 106, 73, 86, 88, 76, 67, 68, 77, 105, 118, 120, 73, 99, 100, 45, 47, 92, 42, 73, 118, 85, 58, 126, 84, 118, 85, 69, 105, 112, 97, 73, 47, 88, 40, 41, 60, 62, 40, 41, 123, 125, 43, 45, 47, 92, 84, 120, 120, 92, 47, 92, 120, 114, 72, 73, 75, 77, 78, 79, 111, 80, 112, 67, 99, 84, 89, 88, 45, 47, 57, 51, 76, 54, 86, 69, 73, 33, 79, 81, 88, 61, 92, 47, 79, 40, 41, 47, 61, 47, 92, 92, 47, 66, 80, 100, 68, 84, 71, 75, 74, 67, 90, 70, 77, 78, 76, 83, 82, 86, 72, 87, 88, 89, 65, 69, 73, 79, 85, 46, 44, 58, 61, 46, 50, 105, 86, 63, 50, 115, 50, 51, 57, 38, 58, 96, 70, 102, 117, 51, 74, 88, 66, 101, 102, 111, 114, 114, 117, 117, 121, 105, 114, 119, 122, 118, 115, 99, 111, 111, 111, 111, 111, 111, 111, 111, 40, 41, 58, 95, 95, 95, 45, 92, 108, 108, 111, 111, 111, 111, 34, 36, 37, 38, 96, 42, 43, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 60, 61, 62, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 73, 66, 69, 70, 124, 88, 79, 80, 83, 84, 43, 65, 66, 67, 70, 79, 77, 84, 89, 88, 72, 90, 66, 67, 124, 77, 84, 88, 56, 42, 108, 88, 79, 67, 76, 83, 111, 99, 115, 82, 79, 85, 55, 111, 117, 78, 79, 75, 67, 86, 70, 76, 88, 46, 79, 118, 119, 119, 119, 86, 70, 76, 89, 69, 90, 57, 69, 52, 76, 79, 85, 53, 84, 118, 115, 70, 105, 122, 55, 111, 51, 57, 54, 57, 111, 117, 121, 79, 90, 87, 67, 88, 87, 67, 86, 84, 76, 73, 82, 83, 51, 62, 65, 85, 89, 96, 96, 123, 46, 51, 86, 92, 55, 70, 82, 76, 60, 62, 47, 92, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 105, 106, 107, 108, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 67, 68, 71, 74, 75, 78, 79, 80, 81, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 102, 104, 105, 106, 107, 108, 110, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 68, 69, 70, 71, 74, 75, 76, 77, 78, 79, 80, 81, 83, 84, 85, 86, 87, 88, 89, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 68, 69, 70, 71, 73, 74, 75, 76, 77, 79, 83, 84, 85, 86, 87, 88, 89, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 73, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 105, 65, 66, 69, 90, 72, 73, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 112, 65, 66, 69, 90, 72, 73, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 112, 65, 66, 69, 90, 72, 73, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 112, 65, 66, 69, 90, 72, 73, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 112, 65, 66, 69, 90, 72, 73, 75, 77, 78, 79, 80, 84, 89, 88, 97, 121, 105, 118, 111, 112, 111, 117, 112, 70, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57, 108, 56, 108, 111, 111, 108, 111, 67, 84, 79, 73, 50, 51, 52, 53, 54, 55, 56, 57},
+ Locale: "_common",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 184, Hi: 383, Stride: 199},
+ {Lo: 388, Hi: 397, Stride: 9},
+ {Lo: 422, Hi: 423, Stride: 1},
+ {Lo: 439, Hi: 444, Stride: 5},
+ {Lo: 445, Hi: 451, Stride: 3},
+ {Lo: 540, Hi: 546, Stride: 6},
+ {Lo: 547, Hi: 577, Stride: 30},
+ {Lo: 593, Hi: 609, Stride: 16},
+ {Lo: 611, Hi: 617, Stride: 6},
+ {Lo: 618, Hi: 623, Stride: 5},
+ {Lo: 651, Hi: 655, Stride: 4},
+ {Lo: 660, Hi: 697, Stride: 37},
+ {Lo: 699, Hi: 702, Stride: 1},
+ {Lo: 706, Hi: 708, Stride: 1},
+ {Lo: 710, Hi: 714, Stride: 2},
+ {Lo: 715, Hi: 720, Stride: 5},
+ {Lo: 727, Hi: 731, Stride: 4},
+ {Lo: 732, Hi: 756, Stride: 24},
+ {Lo: 760, Hi: 884, Stride: 124},
+ {Lo: 890, Hi: 894, Stride: 4},
+ {Lo: 895, Hi: 900, Stride: 5},
+ {Lo: 913, Hi: 914, Stride: 1},
+ {Lo: 917, Hi: 919, Stride: 1},
+ {Lo: 922, Hi: 924, Stride: 2},
+ {Lo: 925, Hi: 929, Stride: 2},
+ {Lo: 932, Hi: 933, Stride: 1},
+ {Lo: 935, Hi: 945, Stride: 10},
+ {Lo: 947, Hi: 953, Stride: 6},
+ {Lo: 957, Hi: 965, Stride: 2},
+ {Lo: 978, Hi: 988, Stride: 10},
+ {Lo: 1000, Hi: 1010, Stride: 10},
+ {Lo: 1011, Hi: 1017, Stride: 6},
+ {Lo: 1018, Hi: 1029, Stride: 11},
+ {Lo: 1030, Hi: 1032, Stride: 2},
+ {Lo: 1109, Hi: 1110, Stride: 1},
+ {Lo: 1112, Hi: 1121, Stride: 9},
+ {Lo: 1140, Hi: 1141, Stride: 1},
+ {Lo: 1198, Hi: 1199, Stride: 1},
+ {Lo: 1211, Hi: 1213, Stride: 2},
+ {Lo: 1216, Hi: 1231, Stride: 15},
+ {Lo: 1248, Hi: 1281, Stride: 33},
+ {Lo: 1292, Hi: 1307, Stride: 15},
+ {Lo: 1308, Hi: 1309, Stride: 1},
+ {Lo: 1357, Hi: 1359, Stride: 2},
+ {Lo: 1365, Hi: 1370, Stride: 5},
+ {Lo: 1373, Hi: 1377, Stride: 4},
+ {Lo: 1379, Hi: 1382, Stride: 3},
+ {Lo: 1392, Hi: 1400, Stride: 8},
+ {Lo: 1404, Hi: 1405, Stride: 1},
+ {Lo: 1409, Hi: 1412, Stride: 3},
+ {Lo: 1413, Hi: 1417, Stride: 4},
+ {Lo: 1472, Hi: 1475, Stride: 3},
+ {Lo: 1493, Hi: 1496, Stride: 3},
+ {Lo: 1497, Hi: 1503, Stride: 6},
+ {Lo: 1505, Hi: 1523, Stride: 18},
+ {Lo: 1549, Hi: 1575, Stride: 26},
+ {Lo: 1607, Hi: 1632, Stride: 25},
+ {Lo: 1633, Hi: 1637, Stride: 4},
+ {Lo: 1639, Hi: 1643, Stride: 4},
+ {Lo: 1645, Hi: 1726, Stride: 81},
+ {Lo: 1729, Hi: 1748, Stride: 19},
+ {Lo: 1749, Hi: 1776, Stride: 27},
+ {Lo: 1777, Hi: 1781, Stride: 4},
+ {Lo: 1783, Hi: 1793, Stride: 10},
+ {Lo: 1794, Hi: 1796, Stride: 1},
+ {Lo: 1984, Hi: 1994, Stride: 10},
+ {Lo: 2036, Hi: 2037, Stride: 1},
+ {Lo: 2042, Hi: 2307, Stride: 265},
+ {Lo: 2406, Hi: 2429, Stride: 23},
+ {Lo: 2534, Hi: 2538, Stride: 4},
+ {Lo: 2541, Hi: 2662, Stride: 121},
+ {Lo: 2663, Hi: 2666, Stride: 3},
+ {Lo: 2691, Hi: 2790, Stride: 99},
+ {Lo: 2819, Hi: 2848, Stride: 29},
+ {Lo: 2918, Hi: 2920, Stride: 2},
+ {Lo: 3046, Hi: 3074, Stride: 28},
+ {Lo: 3174, Hi: 3202, Stride: 28},
+ {Lo: 3302, Hi: 3330, Stride: 28},
+ {Lo: 3360, Hi: 3430, Stride: 70},
+ {Lo: 3437, Hi: 3458, Stride: 21},
+ {Lo: 3664, Hi: 3792, Stride: 128},
+ {Lo: 4125, Hi: 4160, Stride: 35},
+ {Lo: 4327, Hi: 4351, Stride: 24},
+ {Lo: 4608, Hi: 5024, Stride: 208},
+ {Lo: 5025, Hi: 5026, Stride: 1},
+ {Lo: 5029, Hi: 5033, Stride: 4},
+ {Lo: 5034, Hi: 5036, Stride: 1},
+ {Lo: 5038, Hi: 5043, Stride: 5},
+ {Lo: 5047, Hi: 5051, Stride: 4},
+ {Lo: 5053, Hi: 5056, Stride: 3},
+ {Lo: 5058, Hi: 5059, Stride: 1},
+ {Lo: 5070, Hi: 5071, Stride: 1},
+ {Lo: 5074, Hi: 5076, Stride: 2},
+ {Lo: 5077, Hi: 5081, Stride: 4},
+ {Lo: 5082, Hi: 5086, Stride: 4},
+ {Lo: 5087, Hi: 5090, Stride: 3},
+ {Lo: 5094, Hi: 5095, Stride: 1},
+ {Lo: 5102, Hi: 5107, Stride: 5},
+ {Lo: 5108, Hi: 5120, Stride: 12},
+ {Lo: 5167, Hi: 5171, Stride: 4},
+ {Lo: 5176, Hi: 5194, Stride: 18},
+ {Lo: 5196, Hi: 5229, Stride: 33},
+ {Lo: 5231, Hi: 5234, Stride: 3},
+ {Lo: 5261, Hi: 5290, Stride: 29},
+ {Lo: 5311, Hi: 5441, Stride: 130},
+ {Lo: 5500, Hi: 5501, Stride: 1},
+ {Lo: 5511, Hi: 5551, Stride: 40},
+ {Lo: 5556, Hi: 5573, Stride: 17},
+ {Lo: 5598, Hi: 5610, Stride: 12},
+ {Lo: 5616, Hi: 5623, Stride: 7},
+ {Lo: 5741, Hi: 5742, Stride: 1},
+ {Lo: 5760, Hi: 5810, Stride: 50},
+ {Lo: 5815, Hi: 5825, Stride: 10},
+ {Lo: 5836, Hi: 5845, Stride: 9},
+ {Lo: 5846, Hi: 5868, Stride: 22},
+ {Lo: 5869, Hi: 5941, Stride: 72},
+ {Lo: 6147, Hi: 6153, Stride: 6},
+ {Lo: 7428, Hi: 7439, Stride: 11},
+ {Lo: 7441, Hi: 7452, Stride: 11},
+ {Lo: 7456, Hi: 7458, Stride: 1},
+ {Lo: 7462, Hi: 7555, Stride: 93},
+ {Lo: 7564, Hi: 7837, Stride: 273},
+ {Lo: 7935, Hi: 8125, Stride: 190},
+ {Lo: 8126, Hi: 8128, Stride: 1},
+ {Lo: 8175, Hi: 8189, Stride: 14},
+ {Lo: 8190, Hi: 8192, Stride: 2},
+ {Lo: 8193, Hi: 8202, Stride: 1},
+ {Lo: 8208, Hi: 8210, Stride: 1},
+ {Lo: 8218, Hi: 8219, Stride: 1},
+ {Lo: 8228, Hi: 8232, Stride: 4},
+ {Lo: 8233, Hi: 8239, Stride: 6},
+ {Lo: 8242, Hi: 8249, Stride: 7},
+ {Lo: 8250, Hi: 8257, Stride: 7},
+ {Lo: 8259, Hi: 8260, Stride: 1},
+ {Lo: 8270, Hi: 8275, Stride: 5},
+ {Lo: 8282, Hi: 8287, Stride: 5},
+ {Lo: 8450, Hi: 8458, Stride: 8},
+ {Lo: 8459, Hi: 8462, Stride: 1},
+ {Lo: 8464, Hi: 8467, Stride: 1},
+ {Lo: 8469, Hi: 8473, Stride: 4},
+ {Lo: 8474, Hi: 8477, Stride: 1},
+ {Lo: 8484, Hi: 8488, Stride: 4},
+ {Lo: 8490, Hi: 8492, Stride: 2},
+ {Lo: 8493, Hi: 8497, Stride: 1},
+ {Lo: 8499, Hi: 8500, Stride: 1},
+ {Lo: 8505, Hi: 8509, Stride: 4},
+ {Lo: 8517, Hi: 8521, Stride: 1},
+ {Lo: 8544, Hi: 8548, Stride: 4},
+ {Lo: 8553, Hi: 8556, Stride: 3},
+ {Lo: 8557, Hi: 8560, Stride: 1},
+ {Lo: 8564, Hi: 8569, Stride: 5},
+ {Lo: 8572, Hi: 8574, Stride: 1},
+ {Lo: 8722, Hi: 8725, Stride: 3},
+ {Lo: 8726, Hi: 8727, Stride: 1},
+ {Lo: 8739, Hi: 8744, Stride: 5},
+ {Lo: 8746, Hi: 8758, Stride: 12},
+ {Lo: 8764, Hi: 8868, Stride: 104},
+ {Lo: 8897, Hi: 8899, Stride: 2},
+ {Lo: 8959, Hi: 9075, Stride: 116},
+ {Lo: 9076, Hi: 9082, Stride: 6},
+ {Lo: 9213, Hi: 9585, Stride: 372},
+ {Lo: 9587, Hi: 10088, Stride: 501},
+ {Lo: 10089, Hi: 10094, Stride: 5},
+ {Lo: 10095, Hi: 10098, Stride: 3},
+ {Lo: 10099, Hi: 10101, Stride: 1},
+ {Lo: 10133, Hi: 10134, Stride: 1},
+ {Lo: 10187, Hi: 10189, Stride: 2},
+ {Lo: 10201, Hi: 10539, Stride: 338},
+ {Lo: 10540, Hi: 10741, Stride: 201},
+ {Lo: 10744, Hi: 10745, Stride: 1},
+ {Lo: 10799, Hi: 11397, Stride: 598},
+ {Lo: 11406, Hi: 11410, Stride: 4},
+ {Lo: 11412, Hi: 11416, Stride: 4},
+ {Lo: 11418, Hi: 11422, Stride: 4},
+ {Lo: 11423, Hi: 11426, Stride: 3},
+ {Lo: 11427, Hi: 11430, Stride: 1},
+ {Lo: 11432, Hi: 11436, Stride: 4},
+ {Lo: 11450, Hi: 11462, Stride: 12},
+ {Lo: 11466, Hi: 11468, Stride: 2},
+ {Lo: 11472, Hi: 11474, Stride: 2},
+ {Lo: 11576, Hi: 11577, Stride: 1},
+ {Lo: 11599, Hi: 11601, Stride: 2},
+ {Lo: 11604, Hi: 11605, Stride: 1},
+ {Lo: 11613, Hi: 11840, Stride: 227},
+ {Lo: 12034, Hi: 12035, Stride: 1},
+ {Lo: 12295, Hi: 12308, Stride: 13},
+ {Lo: 12309, Hi: 12339, Stride: 30},
+ {Lo: 12448, Hi: 12755, Stride: 307},
+ {Lo: 12756, Hi: 20022, Stride: 7266},
+ {Lo: 20031, Hi: 42192, Stride: 22161},
+ {Lo: 42193, Hi: 42196, Stride: 1},
+ {Lo: 42198, Hi: 42199, Stride: 1},
+ {Lo: 42201, Hi: 42202, Stride: 1},
+ {Lo: 42204, Hi: 42205, Stride: 1},
+ {Lo: 42207, Hi: 42211, Stride: 1},
+ {Lo: 42214, Hi: 42215, Stride: 1},
+ {Lo: 42218, Hi: 42220, Stride: 1},
+ {Lo: 42222, Hi: 42226, Stride: 2},
+ {Lo: 42227, Hi: 42228, Stride: 1},
+ {Lo: 42232, Hi: 42233, Stride: 1},
+ {Lo: 42237, Hi: 42239, Stride: 2},
+ {Lo: 42510, Hi: 42564, Stride: 54},
+ {Lo: 42567, Hi: 42719, Stride: 152},
+ {Lo: 42731, Hi: 42735, Stride: 4},
+ {Lo: 42801, Hi: 42842, Stride: 41},
+ {Lo: 42858, Hi: 42862, Stride: 4},
+ {Lo: 42872, Hi: 42889, Stride: 17},
+ {Lo: 42892, Hi: 42904, Stride: 12},
+ {Lo: 42905, Hi: 42911, Stride: 6},
+ {Lo: 42923, Hi: 42930, Stride: 7},
+ {Lo: 42931, Hi: 42932, Stride: 1},
+ {Lo: 43826, Hi: 43829, Stride: 3},
+ {Lo: 43837, Hi: 43847, Stride: 10},
+ {Lo: 43848, Hi: 43854, Stride: 6},
+ {Lo: 43858, Hi: 43866, Stride: 8},
+ {Lo: 43893, Hi: 43905, Stride: 12},
+ {Lo: 43907, Hi: 43923, Stride: 16},
+ {Lo: 43945, Hi: 43946, Stride: 1},
+ {Lo: 43951, Hi: 64422, Stride: 20471},
+ {Lo: 64423, Hi: 64429, Stride: 1},
+ {Lo: 64830, Hi: 64831, Stride: 1},
+ {Lo: 65072, Hi: 65101, Stride: 29},
+ {Lo: 65102, Hi: 65103, Stride: 1},
+ {Lo: 65112, Hi: 65128, Stride: 16},
+ {Lo: 65165, Hi: 65166, Stride: 1},
+ {Lo: 65257, Hi: 65260, Stride: 1},
+ {Lo: 65282, Hi: 65284, Stride: 2},
+ {Lo: 65285, Hi: 65287, Stride: 1},
+ {Lo: 65290, Hi: 65291, Stride: 1},
+ {Lo: 65293, Hi: 65305, Stride: 1},
+ {Lo: 65308, Hi: 65310, Stride: 1},
+ {Lo: 65312, Hi: 65373, Stride: 1},
+ {Lo: 65512, Hi: 65512, Stride: 1},
+ },
+ R32: []unicode.Range32{
+ {Lo: 66178, Hi: 66182, Stride: 4},
+ {Lo: 66183, Hi: 66186, Stride: 3},
+ {Lo: 66192, Hi: 66194, Stride: 2},
+ {Lo: 66197, Hi: 66199, Stride: 1},
+ {Lo: 66203, Hi: 66208, Stride: 5},
+ {Lo: 66209, Hi: 66210, Stride: 1},
+ {Lo: 66213, Hi: 66219, Stride: 6},
+ {Lo: 66224, Hi: 66226, Stride: 1},
+ {Lo: 66228, Hi: 66255, Stride: 27},
+ {Lo: 66293, Hi: 66305, Stride: 12},
+ {Lo: 66306, Hi: 66313, Stride: 7},
+ {Lo: 66321, Hi: 66325, Stride: 4},
+ {Lo: 66327, Hi: 66330, Stride: 3},
+ {Lo: 66335, Hi: 66336, Stride: 1},
+ {Lo: 66338, Hi: 66564, Stride: 226},
+ {Lo: 66581, Hi: 66587, Stride: 6},
+ {Lo: 66592, Hi: 66604, Stride: 12},
+ {Lo: 66621, Hi: 66632, Stride: 11},
+ {Lo: 66740, Hi: 66754, Stride: 14},
+ {Lo: 66766, Hi: 66770, Stride: 4},
+ {Lo: 66794, Hi: 66806, Stride: 12},
+ {Lo: 66835, Hi: 66838, Stride: 3},
+ {Lo: 66840, Hi: 66844, Stride: 4},
+ {Lo: 66845, Hi: 66853, Stride: 8},
+ {Lo: 66854, Hi: 66855, Stride: 1},
+ {Lo: 68176, Hi: 70864, Stride: 2688},
+ {Lo: 71430, Hi: 71438, Stride: 4},
+ {Lo: 71439, Hi: 71840, Stride: 401},
+ {Lo: 71842, Hi: 71844, Stride: 1},
+ {Lo: 71846, Hi: 71852, Stride: 3},
+ {Lo: 71854, Hi: 71855, Stride: 1},
+ {Lo: 71858, Hi: 71867, Stride: 3},
+ {Lo: 71868, Hi: 71872, Stride: 4},
+ {Lo: 71873, Hi: 71876, Stride: 1},
+ {Lo: 71878, Hi: 71884, Stride: 2},
+ {Lo: 71893, Hi: 71896, Stride: 1},
+ {Lo: 71900, Hi: 71904, Stride: 4},
+ {Lo: 71909, Hi: 71910, Stride: 1},
+ {Lo: 71913, Hi: 71922, Stride: 3},
+ {Lo: 93960, Hi: 93962, Stride: 2},
+ {Lo: 93974, Hi: 93992, Stride: 18},
+ {Lo: 94005, Hi: 94010, Stride: 5},
+ {Lo: 94011, Hi: 94015, Stride: 4},
+ {Lo: 94016, Hi: 94018, Stride: 2},
+ {Lo: 94019, Hi: 94033, Stride: 14},
+ {Lo: 94034, Hi: 119060, Stride: 25026},
+ {Lo: 119149, Hi: 119302, Stride: 153},
+ {Lo: 119309, Hi: 119311, Stride: 2},
+ {Lo: 119314, Hi: 119315, Stride: 1},
+ {Lo: 119318, Hi: 119338, Stride: 20},
+ {Lo: 119350, Hi: 119351, Stride: 1},
+ {Lo: 119354, Hi: 119355, Stride: 1},
+ {Lo: 119808, Hi: 119845, Stride: 1},
+ {Lo: 119847, Hi: 119892, Stride: 1},
+ {Lo: 119894, Hi: 119897, Stride: 1},
+ {Lo: 119899, Hi: 119949, Stride: 1},
+ {Lo: 119951, Hi: 119964, Stride: 1},
+ {Lo: 119966, Hi: 119967, Stride: 1},
+ {Lo: 119970, Hi: 119973, Stride: 3},
+ {Lo: 119974, Hi: 119977, Stride: 3},
+ {Lo: 119978, Hi: 119980, Stride: 1},
+ {Lo: 119982, Hi: 119993, Stride: 1},
+ {Lo: 119995, Hi: 119997, Stride: 2},
+ {Lo: 119998, Hi: 120001, Stride: 1},
+ {Lo: 120003, Hi: 120005, Stride: 2},
+ {Lo: 120006, Hi: 120053, Stride: 1},
+ {Lo: 120055, Hi: 120069, Stride: 1},
+ {Lo: 120071, Hi: 120074, Stride: 1},
+ {Lo: 120077, Hi: 120084, Stride: 1},
+ {Lo: 120086, Hi: 120092, Stride: 1},
+ {Lo: 120094, Hi: 120105, Stride: 1},
+ {Lo: 120107, Hi: 120121, Stride: 1},
+ {Lo: 120123, Hi: 120126, Stride: 1},
+ {Lo: 120128, Hi: 120132, Stride: 1},
+ {Lo: 120134, Hi: 120138, Stride: 4},
+ {Lo: 120139, Hi: 120144, Stride: 1},
+ {Lo: 120146, Hi: 120157, Stride: 1},
+ {Lo: 120159, Hi: 120209, Stride: 1},
+ {Lo: 120211, Hi: 120261, Stride: 1},
+ {Lo: 120263, Hi: 120313, Stride: 1},
+ {Lo: 120315, Hi: 120365, Stride: 1},
+ {Lo: 120367, Hi: 120417, Stride: 1},
+ {Lo: 120419, Hi: 120469, Stride: 1},
+ {Lo: 120471, Hi: 120484, Stride: 1},
+ {Lo: 120488, Hi: 120489, Stride: 1},
+ {Lo: 120492, Hi: 120494, Stride: 1},
+ {Lo: 120496, Hi: 120497, Stride: 1},
+ {Lo: 120499, Hi: 120500, Stride: 1},
+ {Lo: 120502, Hi: 120504, Stride: 2},
+ {Lo: 120507, Hi: 120508, Stride: 1},
+ {Lo: 120510, Hi: 120514, Stride: 4},
+ {Lo: 120516, Hi: 120522, Stride: 6},
+ {Lo: 120526, Hi: 120534, Stride: 2},
+ {Lo: 120544, Hi: 120546, Stride: 2},
+ {Lo: 120547, Hi: 120550, Stride: 3},
+ {Lo: 120551, Hi: 120552, Stride: 1},
+ {Lo: 120554, Hi: 120555, Stride: 1},
+ {Lo: 120557, Hi: 120558, Stride: 1},
+ {Lo: 120560, Hi: 120562, Stride: 2},
+ {Lo: 120565, Hi: 120566, Stride: 1},
+ {Lo: 120568, Hi: 120572, Stride: 4},
+ {Lo: 120574, Hi: 120580, Stride: 6},
+ {Lo: 120584, Hi: 120592, Stride: 2},
+ {Lo: 120602, Hi: 120604, Stride: 2},
+ {Lo: 120605, Hi: 120608, Stride: 3},
+ {Lo: 120609, Hi: 120610, Stride: 1},
+ {Lo: 120612, Hi: 120613, Stride: 1},
+ {Lo: 120615, Hi: 120616, Stride: 1},
+ {Lo: 120618, Hi: 120620, Stride: 2},
+ {Lo: 120623, Hi: 120624, Stride: 1},
+ {Lo: 120626, Hi: 120630, Stride: 4},
+ {Lo: 120632, Hi: 120638, Stride: 6},
+ {Lo: 120642, Hi: 120650, Stride: 2},
+ {Lo: 120660, Hi: 120662, Stride: 2},
+ {Lo: 120663, Hi: 120666, Stride: 3},
+ {Lo: 120667, Hi: 120668, Stride: 1},
+ {Lo: 120670, Hi: 120671, Stride: 1},
+ {Lo: 120673, Hi: 120674, Stride: 1},
+ {Lo: 120676, Hi: 120678, Stride: 2},
+ {Lo: 120681, Hi: 120682, Stride: 1},
+ {Lo: 120684, Hi: 120688, Stride: 4},
+ {Lo: 120690, Hi: 120696, Stride: 6},
+ {Lo: 120700, Hi: 120708, Stride: 2},
+ {Lo: 120718, Hi: 120720, Stride: 2},
+ {Lo: 120721, Hi: 120724, Stride: 3},
+ {Lo: 120725, Hi: 120726, Stride: 1},
+ {Lo: 120728, Hi: 120729, Stride: 1},
+ {Lo: 120731, Hi: 120732, Stride: 1},
+ {Lo: 120734, Hi: 120736, Stride: 2},
+ {Lo: 120739, Hi: 120740, Stride: 1},
+ {Lo: 120742, Hi: 120746, Stride: 4},
+ {Lo: 120748, Hi: 120754, Stride: 6},
+ {Lo: 120758, Hi: 120766, Stride: 2},
+ {Lo: 120776, Hi: 120778, Stride: 2},
+ {Lo: 120782, Hi: 120831, Stride: 1},
+ {Lo: 125127, Hi: 125131, Stride: 4},
+ {Lo: 126464, Hi: 126500, Stride: 36},
+ {Lo: 126564, Hi: 126592, Stride: 28},
+ {Lo: 126596, Hi: 128844, Stride: 2248},
+ {Lo: 128872, Hi: 130032, Stride: 1160},
+ {Lo: 130033, Hi: 130041, Stride: 1},
+ },
+ LatinOffset: 0,
+ },
+ },
+ "_default": {
+ Confusable: []rune{160, 180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{32, 96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "_default",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 160, Hi: 180, Stride: 20},
+ {Lo: 215, Hi: 305, Stride: 90},
+ {Lo: 921, Hi: 1009, Stride: 88},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8216, Stride: 5},
+ {Lo: 8217, Hi: 8245, Stride: 28},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "cs": {
+ Confusable: []rune{180, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "cs",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 305, Stride: 125},
+ {Lo: 921, Hi: 1009, Stride: 88},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8216, Hi: 8217, Stride: 1},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65288, Hi: 65289, Stride: 1},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65311, Stride: 4},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 0,
+ },
+ },
+ "de": {
+ Confusable: []rune{180, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "de",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 305, Stride: 125},
+ {Lo: 921, Hi: 1009, Stride: 88},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8216, Hi: 8217, Stride: 1},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65288, Hi: 65289, Stride: 1},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65311, Stride: 4},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 0,
+ },
+ },
+ "es": {
+ Confusable: []rune{180, 215, 305, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 120, 105, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "es",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 1009, Stride: 704},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8245, Stride: 34},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "fr": {
+ Confusable: []rune{215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8216, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "fr",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 215, Hi: 305, Stride: 90},
+ {Lo: 921, Hi: 1009, Stride: 88},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8216, Hi: 8245, Stride: 29},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 0,
+ },
+ },
+ "it": {
+ Confusable: []rune{160, 180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8216, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{32, 96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "it",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 160, Hi: 180, Stride: 20},
+ {Lo: 215, Hi: 305, Stride: 90},
+ {Lo: 921, Hi: 1009, Stride: 88},
+ {Lo: 1040, Hi: 1042, Stride: 2},
+ {Lo: 1045, Hi: 1047, Stride: 2},
+ {Lo: 1050, Hi: 1052, Stride: 2},
+ {Lo: 1053, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8216, Stride: 5},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65288, Hi: 65289, Stride: 1},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65311, Stride: 4},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "ja": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8216, 8217, 8245, 65281, 65283, 65292, 65306, 65307},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 96, 96, 33, 35, 44, 58, 59},
+ Locale: "ja",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8216, Stride: 5},
+ {Lo: 8217, Hi: 8245, Stride: 28},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65307, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "ko": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "ko",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8245, Stride: 34},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "pl": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "pl",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8216, Hi: 8217, Stride: 1},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65288, Hi: 65289, Stride: 1},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65311, Stride: 4},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "pt-BR": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "pt-BR",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8216, Hi: 8217, Stride: 1},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65283, Stride: 2},
+ {Lo: 65288, Hi: 65289, Stride: 1},
+ {Lo: 65292, Hi: 65306, Stride: 14},
+ {Lo: 65307, Hi: 65311, Stride: 4},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "qps-ploc": {
+ Confusable: []rune{160, 180, 215, 305, 921, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{32, 96, 120, 105, 73, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "qps-ploc",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 160, Hi: 180, Stride: 20},
+ {Lo: 215, Hi: 305, Stride: 90},
+ {Lo: 921, Hi: 1040, Stride: 119},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8216, Stride: 5},
+ {Lo: 8217, Hi: 8245, Stride: 28},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "ru": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 8216, 8217, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{96, 120, 105, 73, 112, 96, 96, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "ru",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 8216, Stride: 7207},
+ {Lo: 8217, Hi: 8245, Stride: 28},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "tr": {
+ Confusable: []rune{160, 180, 215, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 8245, 12494, 65281, 65283, 65288, 65289, 65292, 65306, 65307, 65311, 65374},
+ With: []rune{32, 96, 120, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 96, 47, 33, 35, 40, 41, 44, 58, 59, 63, 126},
+ Locale: "tr",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 160, Hi: 180, Stride: 20},
+ {Lo: 215, Hi: 921, Stride: 706},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 8245, Stride: 34},
+ {Lo: 12494, Hi: 65281, Stride: 52787},
+ {Lo: 65283, Hi: 65288, Stride: 5},
+ {Lo: 65289, Hi: 65292, Stride: 3},
+ {Lo: 65306, Hi: 65307, Stride: 1},
+ {Lo: 65311, Hi: 65374, Stride: 63},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "zh-hans": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8245, 12494, 65281, 65288, 65289, 65306, 65374},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 96, 47, 33, 40, 41, 58, 126},
+ Locale: "zh-hans",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8245, Hi: 12494, Stride: 4249},
+ {Lo: 65281, Hi: 65288, Stride: 7},
+ {Lo: 65289, Hi: 65306, Stride: 17},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+ "zh-hant": {
+ Confusable: []rune{180, 215, 305, 921, 1009, 1040, 1042, 1045, 1047, 1050, 1052, 1053, 1054, 1056, 1057, 1058, 1059, 1061, 1068, 1072, 1073, 1075, 1077, 1086, 1088, 1089, 1091, 1093, 8211, 12494, 65283, 65307, 65374},
+ With: []rune{96, 120, 105, 73, 112, 65, 66, 69, 51, 75, 77, 72, 79, 80, 67, 84, 89, 88, 98, 97, 54, 114, 101, 111, 112, 99, 121, 120, 45, 47, 35, 59, 126},
+ Locale: "zh-hant",
+ RangeTable: &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 180, Hi: 215, Stride: 35},
+ {Lo: 305, Hi: 921, Stride: 616},
+ {Lo: 1009, Hi: 1040, Stride: 31},
+ {Lo: 1042, Hi: 1045, Stride: 3},
+ {Lo: 1047, Hi: 1050, Stride: 3},
+ {Lo: 1052, Hi: 1054, Stride: 1},
+ {Lo: 1056, Hi: 1059, Stride: 1},
+ {Lo: 1061, Hi: 1068, Stride: 7},
+ {Lo: 1072, Hi: 1073, Stride: 1},
+ {Lo: 1075, Hi: 1077, Stride: 2},
+ {Lo: 1086, Hi: 1088, Stride: 2},
+ {Lo: 1089, Hi: 1093, Stride: 2},
+ {Lo: 8211, Hi: 12494, Stride: 4283},
+ {Lo: 65283, Hi: 65307, Stride: 24},
+ {Lo: 65374, Hi: 65374, Stride: 1},
+ },
+ R32: []unicode.Range32{},
+ LatinOffset: 1,
+ },
+ },
+}
diff --git a/modules/charset/ambiguous_gen_test.go b/modules/charset/ambiguous_gen_test.go
new file mode 100644
index 0000000..221c27d
--- /dev/null
+++ b/modules/charset/ambiguous_gen_test.go
@@ -0,0 +1,31 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "sort"
+ "testing"
+ "unicode"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAmbiguousCharacters(t *testing.T) {
+ for locale, ambiguous := range AmbiguousCharacters {
+ assert.Equal(t, locale, ambiguous.Locale)
+ assert.Equal(t, len(ambiguous.Confusable), len(ambiguous.With))
+ assert.True(t, sort.SliceIsSorted(ambiguous.Confusable, func(i, j int) bool {
+ return ambiguous.Confusable[i] < ambiguous.Confusable[j]
+ }))
+
+ for _, confusable := range ambiguous.Confusable {
+ assert.True(t, unicode.Is(ambiguous.RangeTable, confusable))
+ i := sort.Search(len(ambiguous.Confusable), func(j int) bool {
+ return ambiguous.Confusable[j] >= confusable
+ })
+ found := i < len(ambiguous.Confusable) && ambiguous.Confusable[i] == confusable
+ assert.True(t, found, "%c is not in %d", confusable, i)
+ }
+ }
+}
diff --git a/modules/charset/breakwriter.go b/modules/charset/breakwriter.go
new file mode 100644
index 0000000..a87e846
--- /dev/null
+++ b/modules/charset/breakwriter.go
@@ -0,0 +1,43 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "bytes"
+ "io"
+)
+
+// BreakWriter wraps an io.Writer to always write '\n' as '<br>'
+type BreakWriter struct {
+ io.Writer
+}
+
+// Write writes the provided byte slice transparently replacing '\n' with '<br>'
+func (b *BreakWriter) Write(bs []byte) (n int, err error) {
+ pos := 0
+ for pos < len(bs) {
+ idx := bytes.IndexByte(bs[pos:], '\n')
+ if idx < 0 {
+ wn, err := b.Writer.Write(bs[pos:])
+ return n + wn, err
+ }
+
+ if idx > 0 {
+ wn, err := b.Writer.Write(bs[pos : pos+idx])
+ n += wn
+ if err != nil {
+ return n, err
+ }
+ }
+
+ if _, err = b.Writer.Write([]byte("<br>")); err != nil {
+ return n, err
+ }
+ pos += idx + 1
+
+ n++
+ }
+
+ return n, err
+}
diff --git a/modules/charset/breakwriter_test.go b/modules/charset/breakwriter_test.go
new file mode 100644
index 0000000..5eeeedc
--- /dev/null
+++ b/modules/charset/breakwriter_test.go
@@ -0,0 +1,68 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBreakWriter_Write(t *testing.T) {
+ tests := []struct {
+ name string
+ kase string
+ expect string
+ wantErr bool
+ }{
+ {
+ name: "noline",
+ kase: "abcdefghijklmnopqrstuvwxyz",
+ expect: "abcdefghijklmnopqrstuvwxyz",
+ },
+ {
+ name: "endline",
+ kase: "abcdefghijklmnopqrstuvwxyz\n",
+ expect: "abcdefghijklmnopqrstuvwxyz<br>",
+ },
+ {
+ name: "startline",
+ kase: "\nabcdefghijklmnopqrstuvwxyz",
+ expect: "<br>abcdefghijklmnopqrstuvwxyz",
+ },
+ {
+ name: "onlyline",
+ kase: "\n\n\n",
+ expect: "<br><br><br>",
+ },
+ {
+ name: "empty",
+ kase: "",
+ expect: "",
+ },
+ {
+ name: "midline",
+ kase: "\nabc\ndefghijkl\nmnopqrstuvwxy\nz",
+ expect: "<br>abc<br>defghijkl<br>mnopqrstuvwxy<br>z",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ buf := &strings.Builder{}
+ b := &BreakWriter{
+ Writer: buf,
+ }
+ n, err := b.Write([]byte(tt.kase))
+ if (err != nil) != tt.wantErr {
+ t.Errorf("BreakWriter.Write() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if n != len(tt.kase) {
+ t.Errorf("BreakWriter.Write() = %v, want %v", n, len(tt.kase))
+ }
+ if buf.String() != tt.expect {
+ t.Errorf("BreakWriter.Write() wrote %q, want %v", buf.String(), tt.expect)
+ }
+ })
+ }
+}
diff --git a/modules/charset/charset.go b/modules/charset/charset.go
new file mode 100644
index 0000000..1855446
--- /dev/null
+++ b/modules/charset/charset.go
@@ -0,0 +1,211 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/gogs/chardet"
+ "golang.org/x/net/html/charset"
+ "golang.org/x/text/transform"
+)
+
+// UTF8BOM is the utf-8 byte-order marker
+var UTF8BOM = []byte{'\xef', '\xbb', '\xbf'}
+
+type ConvertOpts struct {
+ KeepBOM bool
+}
+
+// ToUTF8WithFallbackReader detects the encoding of content and converts to UTF-8 reader if possible
+func ToUTF8WithFallbackReader(rd io.Reader, opts ConvertOpts) io.Reader {
+ buf := make([]byte, 2048)
+ n, err := util.ReadAtMost(rd, buf)
+ if err != nil {
+ return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd)
+ }
+
+ charsetLabel, err := DetectEncoding(buf[:n])
+ if err != nil || charsetLabel == "UTF-8" {
+ return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd)
+ }
+
+ encoding, _ := charset.Lookup(charsetLabel)
+ if encoding == nil {
+ return io.MultiReader(bytes.NewReader(buf[:n]), rd)
+ }
+
+ return transform.NewReader(
+ io.MultiReader(
+ bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)),
+ rd,
+ ),
+ encoding.NewDecoder(),
+ )
+}
+
+// ToUTF8 converts content to UTF8 encoding
+func ToUTF8(content []byte, opts ConvertOpts) (string, error) {
+ charsetLabel, err := DetectEncoding(content)
+ if err != nil {
+ return "", err
+ } else if charsetLabel == "UTF-8" {
+ return string(MaybeRemoveBOM(content, opts)), nil
+ }
+
+ encoding, _ := charset.Lookup(charsetLabel)
+ if encoding == nil {
+ return string(content), fmt.Errorf("Unknown encoding: %s", charsetLabel)
+ }
+
+ // If there is an error, we concatenate the nicely decoded part and the
+ // original left over. This way we won't lose much data.
+ result, n, err := transform.Bytes(encoding.NewDecoder(), content)
+ if err != nil {
+ result = append(result, content[n:]...)
+ }
+
+ result = MaybeRemoveBOM(result, opts)
+
+ return string(result), err
+}
+
+// ToUTF8WithFallback detects the encoding of content and converts to UTF-8 if possible
+func ToUTF8WithFallback(content []byte, opts ConvertOpts) []byte {
+ bs, _ := io.ReadAll(ToUTF8WithFallbackReader(bytes.NewReader(content), opts))
+ return bs
+}
+
+// ToUTF8DropErrors makes sure the return string is valid utf-8; attempts conversion if possible
+func ToUTF8DropErrors(content []byte, opts ConvertOpts) []byte {
+ charsetLabel, err := DetectEncoding(content)
+ if err != nil || charsetLabel == "UTF-8" {
+ return MaybeRemoveBOM(content, opts)
+ }
+
+ encoding, _ := charset.Lookup(charsetLabel)
+ if encoding == nil {
+ return content
+ }
+
+ // We ignore any non-decodable parts from the file.
+ // Some parts might be lost
+ var decoded []byte
+ decoder := encoding.NewDecoder()
+ idx := 0
+ for {
+ result, n, err := transform.Bytes(decoder, content[idx:])
+ decoded = append(decoded, result...)
+ if err == nil {
+ break
+ }
+ decoded = append(decoded, ' ')
+ idx = idx + n + 1
+ if idx >= len(content) {
+ break
+ }
+ }
+
+ return MaybeRemoveBOM(decoded, opts)
+}
+
+// MaybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false
+func MaybeRemoveBOM(content []byte, opts ConvertOpts) []byte {
+ if opts.KeepBOM {
+ return content
+ }
+ if len(content) > 2 && bytes.Equal(content[0:3], UTF8BOM) {
+ return content[3:]
+ }
+ return content
+}
+
+// DetectEncoding detect the encoding of content
+func DetectEncoding(content []byte) (string, error) {
+ // First we check if the content represents valid utf8 content excepting a truncated character at the end.
+
+ // Now we could decode all the runes in turn but this is not necessarily the cheapest thing to do
+ // instead we walk backwards from the end to trim off a the incomplete character
+ toValidate := content
+ end := len(toValidate) - 1
+
+ if end < 0 {
+ // no-op
+ } else if toValidate[end]>>5 == 0b110 {
+ // Incomplete 1 byte extension e.g. © <c2><a9> which has been truncated to <c2>
+ toValidate = toValidate[:end]
+ } else if end > 0 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>4 == 0b1110 {
+ // Incomplete 2 byte extension e.g. â›” <e2><9b><94> which has been truncated to <e2><9b>
+ toValidate = toValidate[:end-1]
+ } else if end > 1 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>6 == 0b10 && toValidate[end-2]>>3 == 0b11110 {
+ // Incomplete 3 byte extension e.g. 💩 <f0><9f><92><a9> which has been truncated to <f0><9f><92>
+ toValidate = toValidate[:end-2]
+ }
+ if utf8.Valid(toValidate) {
+ log.Debug("Detected encoding: utf-8 (fast)")
+ return "UTF-8", nil
+ }
+
+ textDetector := chardet.NewTextDetector()
+ var detectContent []byte
+ if len(content) < 1024 {
+ // Check if original content is valid
+ if _, err := textDetector.DetectBest(content); err != nil {
+ return "", err
+ }
+ times := 1024 / len(content)
+ detectContent = make([]byte, 0, times*len(content))
+ for i := 0; i < times; i++ {
+ detectContent = append(detectContent, content...)
+ }
+ } else {
+ detectContent = content
+ }
+
+ // Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie break
+ results, err := textDetector.DetectAll(detectContent)
+ if err != nil {
+ if err == chardet.NotDetectedError && len(setting.Repository.AnsiCharset) > 0 {
+ log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset)
+ return setting.Repository.AnsiCharset, nil
+ }
+ return "", err
+ }
+
+ topConfidence := results[0].Confidence
+ topResult := results[0]
+ priority, has := setting.Repository.DetectedCharsetScore[strings.ToLower(strings.TrimSpace(topResult.Charset))]
+ for _, result := range results {
+ // As results are sorted in confidence order - if we have a different confidence
+ // we know it's less than the current confidence and can break out of the loop early
+ if result.Confidence != topConfidence {
+ break
+ }
+
+ // Otherwise check if this results is earlier in the DetectedCharsetOrder than our current top guess
+ resultPriority, resultHas := setting.Repository.DetectedCharsetScore[strings.ToLower(strings.TrimSpace(result.Charset))]
+ if resultHas && (!has || resultPriority < priority) {
+ topResult = result
+ priority = resultPriority
+ has = true
+ }
+ }
+
+ // FIXME: to properly decouple this function the fallback ANSI charset should be passed as an argument
+ if topResult.Charset != "UTF-8" && len(setting.Repository.AnsiCharset) > 0 {
+ log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset)
+ return setting.Repository.AnsiCharset, err
+ }
+
+ log.Debug("Detected encoding: %s", topResult.Charset)
+ return topResult.Charset, err
+}
diff --git a/modules/charset/charset_test.go b/modules/charset/charset_test.go
new file mode 100644
index 0000000..42c8415
--- /dev/null
+++ b/modules/charset/charset_test.go
@@ -0,0 +1,385 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func resetDefaultCharsetsOrder() {
+ defaultDetectedCharsetsOrder := make([]string, 0, len(setting.Repository.DetectedCharsetsOrder))
+ for _, charset := range setting.Repository.DetectedCharsetsOrder {
+ defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder, strings.ToLower(strings.TrimSpace(charset)))
+ }
+ setting.Repository.DetectedCharsetScore = map[string]int{}
+ i := 0
+ for _, charset := range defaultDetectedCharsetsOrder {
+ canonicalCharset := strings.ToLower(strings.TrimSpace(charset))
+ if _, has := setting.Repository.DetectedCharsetScore[canonicalCharset]; !has {
+ setting.Repository.DetectedCharsetScore[canonicalCharset] = i
+ i++
+ }
+ }
+}
+
+func TestMaybeRemoveBOM(t *testing.T) {
+ res := MaybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+
+ res = MaybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+}
+
+func TestToUTF8(t *testing.T) {
+ resetDefaultCharsetsOrder()
+ var res string
+ var err error
+
+ // Note: golang compiler seems so behave differently depending on the current
+ // locale, so some conversions might behave differently. For that reason, we don't
+ // depend on particular conversions but in expected behaviors.
+
+ res, err = ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
+ require.NoError(t, err)
+ assert.Equal(t, "ABC", res)
+
+ // "áéíóú"
+ res, err = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res))
+
+ // "áéíóú"
+ res, err = ToUTF8([]byte{
+ 0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3,
+ 0xc3, 0xba,
+ }, ConvertOpts{})
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res))
+
+ res, err = ToUTF8([]byte{
+ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
+ 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
+ }, ConvertOpts{})
+ require.NoError(t, err)
+ stringMustStartWith(t, "Hola,", res)
+ stringMustEndWith(t, "AAA.", res)
+
+ res, err = ToUTF8([]byte{
+ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
+ 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
+ }, ConvertOpts{})
+ require.NoError(t, err)
+ stringMustStartWith(t, "Hola,", res)
+ stringMustEndWith(t, "AAA.", res)
+
+ res, err = ToUTF8([]byte{
+ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
+ 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
+ }, ConvertOpts{})
+ require.NoError(t, err)
+ stringMustStartWith(t, "Hola,", res)
+ stringMustEndWith(t, "AAA.", res)
+
+ // Japanese (Shift-JIS)
+ // 日属秘ãžã—ã¡ã‚…。
+ res, err = ToUTF8([]byte{
+ 0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82,
+ 0xBF, 0x82, 0xE3, 0x81, 0x42,
+ }, ConvertOpts{})
+ require.NoError(t, err)
+ assert.Equal(t, []byte{
+ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3,
+ 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82,
+ },
+ []byte(res))
+
+ res, err = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, []byte(res))
+}
+
+func TestToUTF8WithFallback(t *testing.T) {
+ resetDefaultCharsetsOrder()
+ // "ABC"
+ res := ToUTF8WithFallback([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
+ assert.Equal(t, []byte{0x41, 0x42, 0x43}, res)
+
+ // "áéíóú"
+ res = ToUTF8WithFallback([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+
+ // UTF8 BOM + "áéíóú"
+ res = ToUTF8WithFallback([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+
+ // "Hola, así cómo ños"
+ res = ToUTF8WithFallback([]byte{
+ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
+ 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73,
+ }, ConvertOpts{})
+ assert.Equal(t, []byte{
+ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63,
+ 0xC3, 0xB3, 0x6D, 0x6F, 0x20, 0xC3, 0xB1, 0x6F, 0x73,
+ }, res)
+
+ // "Hola, así cómo "
+ minmatch := []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63, 0xC3, 0xB3, 0x6D, 0x6F, 0x20}
+
+ res = ToUTF8WithFallback([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}, ConvertOpts{})
+ // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
+ assert.Equal(t, minmatch, res[0:len(minmatch)])
+
+ res = ToUTF8WithFallback([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}, ConvertOpts{})
+ // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
+ assert.Equal(t, minmatch, res[0:len(minmatch)])
+
+ // Japanese (Shift-JIS)
+ // "日属秘ãžã—ã¡ã‚…。"
+ res = ToUTF8WithFallback([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}, ConvertOpts{})
+ assert.Equal(t, []byte{
+ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3,
+ 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82,
+ }, res)
+
+ res = ToUTF8WithFallback([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
+ assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
+}
+
+func TestToUTF8DropErrors(t *testing.T) {
+ resetDefaultCharsetsOrder()
+ // "ABC"
+ res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
+ assert.Equal(t, []byte{0x41, 0x42, 0x43}, res)
+
+ // "áéíóú"
+ res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+
+ // UTF8 BOM + "áéíóú"
+ res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
+ assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
+
+ // "Hola, así cómo ños"
+ res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}, ConvertOpts{})
+ assert.Equal(t, []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73}, res[:8])
+ assert.Equal(t, []byte{0x73}, res[len(res)-1:])
+
+ // "Hola, así cómo "
+ minmatch := []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63, 0xC3, 0xB3, 0x6D, 0x6F, 0x20}
+
+ res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}, ConvertOpts{})
+ // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
+ assert.Equal(t, minmatch, res[0:len(minmatch)])
+
+ res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}, ConvertOpts{})
+ // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
+ assert.Equal(t, minmatch, res[0:len(minmatch)])
+
+ // Japanese (Shift-JIS)
+ // "日属秘ãžã—ã¡ã‚…。"
+ res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}, ConvertOpts{})
+ assert.Equal(t, []byte{
+ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3,
+ 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82,
+ }, res)
+
+ res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
+ assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
+}
+
+func TestDetectEncoding(t *testing.T) {
+ resetDefaultCharsetsOrder()
+ testSuccess := func(b []byte, expected string) {
+ encoding, err := DetectEncoding(b)
+ require.NoError(t, err)
+ assert.Equal(t, expected, encoding)
+ }
+ // utf-8
+ b := []byte("just some ascii")
+ testSuccess(b, "UTF-8")
+
+ // utf-8-sig: "hey" (with BOM)
+ b = []byte{0xef, 0xbb, 0xbf, 0x68, 0x65, 0x79}
+ testSuccess(b, "UTF-8")
+
+ // utf-16: "hey<accented G>"
+ b = []byte{0xff, 0xfe, 0x68, 0x00, 0x65, 0x00, 0x79, 0x00, 0xf4, 0x01}
+ testSuccess(b, "UTF-16LE")
+
+ // iso-8859-1: d<accented e>cor<newline>
+ b = []byte{0x44, 0xe9, 0x63, 0x6f, 0x72, 0x0a}
+ encoding, err := DetectEncoding(b)
+ require.NoError(t, err)
+ assert.Contains(t, encoding, "ISO-8859-1")
+
+ old := setting.Repository.AnsiCharset
+ setting.Repository.AnsiCharset = "placeholder"
+ defer func() {
+ setting.Repository.AnsiCharset = old
+ }()
+ testSuccess(b, "placeholder")
+
+ // invalid bytes
+ b = []byte{0xfa}
+ _, err = DetectEncoding(b)
+ require.Error(t, err)
+}
+
+func stringMustStartWith(t *testing.T, expected, value string) {
+ assert.Equal(t, expected, value[:len(expected)])
+}
+
+func stringMustEndWith(t *testing.T, expected, value string) {
+ assert.Equal(t, expected, value[len(value)-len(expected):])
+}
+
+func TestToUTF8WithFallbackReader(t *testing.T) {
+ resetDefaultCharsetsOrder()
+
+ for testLen := 0; testLen < 2048; testLen++ {
+ pattern := " test { () }\n"
+ input := ""
+ for len(input) < testLen {
+ input += pattern
+ }
+ input = input[:testLen]
+ input += "// Выключаем"
+ rd := ToUTF8WithFallbackReader(bytes.NewReader([]byte(input)), ConvertOpts{})
+ r, _ := io.ReadAll(rd)
+ assert.EqualValuesf(t, input, string(r), "testing string len=%d", testLen)
+ }
+
+ truncatedOneByteExtension := failFastBytes
+ encoding, _ := DetectEncoding(truncatedOneByteExtension)
+ assert.Equal(t, "UTF-8", encoding)
+
+ truncatedTwoByteExtension := failFastBytes
+ truncatedTwoByteExtension[len(failFastBytes)-1] = 0x9b
+ truncatedTwoByteExtension[len(failFastBytes)-2] = 0xe2
+
+ encoding, _ = DetectEncoding(truncatedTwoByteExtension)
+ assert.Equal(t, "UTF-8", encoding)
+
+ truncatedThreeByteExtension := failFastBytes
+ truncatedThreeByteExtension[len(failFastBytes)-1] = 0x92
+ truncatedThreeByteExtension[len(failFastBytes)-2] = 0x9f
+ truncatedThreeByteExtension[len(failFastBytes)-3] = 0xf0
+
+ encoding, _ = DetectEncoding(truncatedThreeByteExtension)
+ assert.Equal(t, "UTF-8", encoding)
+}
+
+var failFastBytes = []byte{
+ 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, 0x2e, 0x61, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x74, 0x6f,
+ 0x6f, 0x6c, 0x73, 0x2e, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x64, 0x65, 0x66, 0x73, 0x2e, 0x63, 0x6f, 0x6e,
+ 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4f, 0x73, 0x0a, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67,
+ 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f,
+ 0x74, 0x2e, 0x67, 0x72, 0x61, 0x64, 0x6c, 0x65, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x75, 0x6e, 0x2e, 0x42,
+ 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x0a, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20,
+ 0x20, 0x20, 0x69, 0x64, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d,
+ 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65,
+ 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a,
+ 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d,
+ 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+ 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x2d, 0x64, 0x6f, 0x63, 0x73, 0x22, 0x29,
+ 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+ 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x64, 0x62,
+ 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69,
+ 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a,
+ 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a,
+ 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x66,
+ 0x73, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74,
+ 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
+ 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x6d, 0x71, 0x22, 0x29, 0x29, 0x0a, 0x0a,
+ 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22,
+ 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
+ 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74,
+ 0x65, 0x72, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74,
+ 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63,
+ 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x68, 0x61, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e,
+ 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x22, 0x29, 0x0a,
+ 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28,
+ 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b,
+ 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74,
+ 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x77, 0x65, 0x62, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69,
+ 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72,
+ 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x6f, 0x70,
+ 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f,
+ 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f,
+ 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d,
+ 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x63, 0x74, 0x75, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x29, 0x0a, 0x20,
+ 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f,
+ 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63,
+ 0x6c, 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74,
+ 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x22, 0x29, 0x0a, 0x20, 0x20,
+ 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72,
+ 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c,
+ 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61,
+ 0x72, 0x74, 0x65, 0x72, 0x2d, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6c, 0x2d, 0x61, 0x6c, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20,
+ 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72,
+ 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c,
+ 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61,
+ 0x72, 0x74, 0x65, 0x72, 0x2d, 0x73, 0x6c, 0x65, 0x75, 0x74, 0x68, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d,
+ 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70,
+ 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x72, 0x65, 0x74, 0x72, 0x79, 0x3a,
+ 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x72, 0x65, 0x74, 0x72, 0x79, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
+ 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x63, 0x68, 0x2e, 0x71,
+ 0x6f, 0x73, 0x2e, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x3a, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x2d, 0x63,
+ 0x6c, 0x61, 0x73, 0x73, 0x69, 0x63, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x69, 0x6f, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65,
+ 0x74, 0x65, 0x72, 0x3a, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x2d, 0x72, 0x65, 0x67, 0x69, 0x73,
+ 0x74, 0x72, 0x79, 0x2d, 0x70, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x68, 0x65, 0x75, 0x73, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20,
+ 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x6b, 0x6f, 0x74,
+ 0x6c, 0x69, 0x6e, 0x28, 0x22, 0x73, 0x74, 0x64, 0x6c, 0x69, 0x62, 0x22, 0x29, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
+ 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f,
+ 0x2f, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0x54, 0x65, 0x73, 0x74, 0x20, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
+ 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f,
+ 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x74,
+ 0x65, 0x73, 0x74, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a,
+ 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d,
+ 0x74, 0x65, 0x73, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a,
+ 0x61, 0x72, 0x20, 0x62, 0x79, 0x20, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
+ 0x69, 0x6e, 0x67, 0x28, 0x4a, 0x61, 0x72, 0x3a, 0x3a, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20,
+ 0x20, 0x20, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x65, 0x72, 0x2e,
+ 0x73, 0x65, 0x74, 0x28, 0x22, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
+ 0x76, 0x61, 0x6c, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68,
+ 0x20, 0x62, 0x79, 0x20, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x67,
+ 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
+ 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65,
+ 0x73, 0x28, 0x22, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x2d, 0x50, 0x61, 0x74, 0x68, 0x22, 0x20, 0x74, 0x6f, 0x20, 0x6f, 0x62,
+ 0x6a, 0x65, 0x63, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70,
+ 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x20, 0x3d,
+ 0x20, 0x22, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2b, 0x22, 0x2e, 0x74, 0x6f, 0x52, 0x65, 0x67, 0x65, 0x78, 0x28, 0x29,
+ 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64,
+ 0x65, 0x20, 0x66, 0x75, 0x6e, 0x20, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x3a, 0x20, 0x53, 0x74,
+ 0x72, 0x69, 0x6e, 0x67, 0x20, 0x3d, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70,
+ 0x61, 0x74, 0x68, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x2e, 0x6a, 0x6f, 0x69, 0x6e, 0x54, 0x6f, 0x53, 0x74, 0x72, 0x69,
+ 0x6e, 0x67, 0x28, 0x22, 0x20, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
+ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x55, 0x52, 0x49, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x55,
+ 0x52, 0x4c, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x2e, 0x72, 0x65, 0x70, 0x6c,
+ 0x61, 0x63, 0x65, 0x46, 0x69, 0x72, 0x73, 0x74, 0x28, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x2c, 0x20, 0x22, 0x2f,
+ 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x20, 0x20, 0x20,
+ 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x7d, 0x0a, 0x0a, 0x74, 0x61, 0x73,
+ 0x6b, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x3c, 0x42, 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x3e, 0x28, 0x22, 0x62,
+ 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x28, 0x4f,
+ 0x73, 0x2e, 0x69, 0x73, 0x46, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x28, 0x4f, 0x73, 0x2e, 0x46, 0x41, 0x4d, 0x49, 0x4c, 0x59,
+ 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x29, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
+ 0x20, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, 0x20, 0x3d, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x28, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x74, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x28, 0x22, 0x6d, 0x61, 0x69,
+ 0x6e, 0x22, 0x29, 0x2e, 0x6d, 0x61, 0x70, 0x20, 0x7b, 0x20, 0x69, 0x74, 0x2e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20,
+ 0x7d, 0x2c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, 0x61, 0x72, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x0a,
+ 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0xd0,
+}
diff --git a/modules/charset/escape.go b/modules/charset/escape.go
new file mode 100644
index 0000000..ba0eb73
--- /dev/null
+++ b/modules/charset/escape.go
@@ -0,0 +1,58 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:generate go run invisible/generate.go -v -o ./invisible_gen.go
+
+//go:generate go run ambiguous/generate.go -v -o ./ambiguous_gen.go ambiguous/ambiguous.json
+
+package charset
+
+import (
+ "html/template"
+ "io"
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+)
+
+// RuneNBSP is the codepoint for NBSP
+const RuneNBSP = 0xa0
+
+type escapeContext string
+
+// Keep this consistent with the documentation of [ui].SKIP_ESCAPE_CONTEXTS
+// Defines the different contexts that could be used to escape in.
+const (
+ // Wiki pages.
+ WikiContext escapeContext = "wiki"
+ // Rendered content (except markup), source code and blames.
+ FileviewContext escapeContext = "file-view"
+ // Commits or pull requet's diff.
+ DiffContext escapeContext = "diff"
+)
+
+// EscapeControlHTML escapes the unicode control sequences in a provided html document
+func EscapeControlHTML(html template.HTML, locale translation.Locale, context escapeContext, allowed ...rune) (escaped *EscapeStatus, output template.HTML) {
+ sb := &strings.Builder{}
+ escaped, _ = EscapeControlReader(strings.NewReader(string(html)), sb, locale, context, allowed...) // err has been handled in EscapeControlReader
+ return escaped, template.HTML(sb.String())
+}
+
+// EscapeControlReader escapes the unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus
+func EscapeControlReader(reader io.Reader, writer io.Writer, locale translation.Locale, context escapeContext, allowed ...rune) (escaped *EscapeStatus, err error) {
+ if !setting.UI.AmbiguousUnicodeDetection || slices.Contains(setting.UI.SkipEscapeContexts, string(context)) {
+ _, err = io.Copy(writer, reader)
+ return &EscapeStatus{}, err
+ }
+ outputStream := &HTMLStreamerWriter{Writer: writer}
+ streamer := NewEscapeStreamer(locale, outputStream, allowed...).(*escapeStreamer)
+
+ if err = StreamHTML(reader, streamer); err != nil {
+ streamer.escaped.HasError = true
+ log.Error("Error whilst escaping: %v", err)
+ }
+ return streamer.escaped, err
+}
diff --git a/modules/charset/escape_status.go b/modules/charset/escape_status.go
new file mode 100644
index 0000000..37b6ad8
--- /dev/null
+++ b/modules/charset/escape_status.go
@@ -0,0 +1,27 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+// EscapeStatus represents the findings of the unicode escaper
+type EscapeStatus struct {
+ Escaped bool
+ HasError bool
+ HasBadRunes bool
+ HasInvisible bool
+ HasAmbiguous bool
+}
+
+// Or combines two EscapeStatus structs into one representing the conjunction of the two
+func (status *EscapeStatus) Or(other *EscapeStatus) *EscapeStatus {
+ st := status
+ if status == nil {
+ st = &EscapeStatus{}
+ }
+ st.Escaped = st.Escaped || other.Escaped
+ st.HasError = st.HasError || other.HasError
+ st.HasBadRunes = st.HasBadRunes || other.HasBadRunes
+ st.HasAmbiguous = st.HasAmbiguous || other.HasAmbiguous
+ st.HasInvisible = st.HasInvisible || other.HasInvisible
+ return st
+}
diff --git a/modules/charset/escape_stream.go b/modules/charset/escape_stream.go
new file mode 100644
index 0000000..29943eb
--- /dev/null
+++ b/modules/charset/escape_stream.go
@@ -0,0 +1,289 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/modules/translation"
+
+ "golang.org/x/net/html"
+)
+
+// VScode defaultWordRegexp
+var defaultWordRegexp = regexp.MustCompile(`(-?\d*\.\d\w*)|([^\` + "`" + `\~\!\@\#\$\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s\x00-\x1f]+)`)
+
+func NewEscapeStreamer(locale translation.Locale, next HTMLStreamer, allowed ...rune) HTMLStreamer {
+ allowedM := make(map[rune]bool, len(allowed))
+ for _, v := range allowed {
+ allowedM[v] = true
+ }
+ return &escapeStreamer{
+ escaped: &EscapeStatus{},
+ PassthroughHTMLStreamer: *NewPassthroughStreamer(next),
+ locale: locale,
+ ambiguousTables: AmbiguousTablesForLocale(locale),
+ allowed: allowedM,
+ }
+}
+
+type escapeStreamer struct {
+ PassthroughHTMLStreamer
+ escaped *EscapeStatus
+ locale translation.Locale
+ ambiguousTables []*AmbiguousTable
+ allowed map[rune]bool
+}
+
+func (e *escapeStreamer) EscapeStatus() *EscapeStatus {
+ return e.escaped
+}
+
+// Text tells the next streamer there is a text
+func (e *escapeStreamer) Text(data string) error {
+ sb := &strings.Builder{}
+ var until int
+ var next int
+ pos := 0
+ if len(data) > len(UTF8BOM) && data[:len(UTF8BOM)] == string(UTF8BOM) {
+ _, _ = sb.WriteString(data[:len(UTF8BOM)])
+ pos = len(UTF8BOM)
+ }
+ dataBytes := []byte(data)
+ for pos < len(data) {
+ nextIdxs := defaultWordRegexp.FindStringIndex(data[pos:])
+ if nextIdxs == nil {
+ until = len(data)
+ next = until
+ } else {
+ until, next = nextIdxs[0]+pos, nextIdxs[1]+pos
+ }
+
+ // from pos until we know that the runes are not \r\t\n or even ' '
+ runes := make([]rune, 0, next-until)
+ positions := make([]int, 0, next-until+1)
+
+ for pos < until {
+ r, sz := utf8.DecodeRune(dataBytes[pos:])
+ positions = positions[:0]
+ positions = append(positions, pos, pos+sz)
+ types, confusables, _ := e.runeTypes(r)
+ if err := e.handleRunes(dataBytes, []rune{r}, positions, types, confusables, sb); err != nil {
+ return err
+ }
+ pos += sz
+ }
+
+ for i := pos; i < next; {
+ r, sz := utf8.DecodeRune(dataBytes[i:])
+ runes = append(runes, r)
+ positions = append(positions, i)
+ i += sz
+ }
+ positions = append(positions, next)
+ types, confusables, runeCounts := e.runeTypes(runes...)
+ if runeCounts.needsEscape() {
+ if err := e.handleRunes(dataBytes, runes, positions, types, confusables, sb); err != nil {
+ return err
+ }
+ } else {
+ _, _ = sb.Write(dataBytes[pos:next])
+ }
+ pos = next
+ }
+ if sb.Len() > 0 {
+ if err := e.PassthroughHTMLStreamer.Text(sb.String()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *escapeStreamer) handleRunes(data []byte, runes []rune, positions []int, types []runeType, confusables []rune, sb *strings.Builder) error {
+ for i, r := range runes {
+ switch types[i] {
+ case brokenRuneType:
+ if sb.Len() > 0 {
+ if err := e.PassthroughHTMLStreamer.Text(sb.String()); err != nil {
+ return err
+ }
+ sb.Reset()
+ }
+ end := positions[i+1]
+ start := positions[i]
+ if err := e.brokenRune(data[start:end]); err != nil {
+ return err
+ }
+ case ambiguousRuneType:
+ if sb.Len() > 0 {
+ if err := e.PassthroughHTMLStreamer.Text(sb.String()); err != nil {
+ return err
+ }
+ sb.Reset()
+ }
+ if err := e.ambiguousRune(r, confusables[0]); err != nil {
+ return err
+ }
+ confusables = confusables[1:]
+ case invisibleRuneType:
+ if sb.Len() > 0 {
+ if err := e.PassthroughHTMLStreamer.Text(sb.String()); err != nil {
+ return err
+ }
+ sb.Reset()
+ }
+ if err := e.invisibleRune(r); err != nil {
+ return err
+ }
+ default:
+ _, _ = sb.WriteRune(r)
+ }
+ }
+ return nil
+}
+
+func (e *escapeStreamer) brokenRune(bs []byte) error {
+ e.escaped.Escaped = true
+ e.escaped.HasBadRunes = true
+
+ if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{
+ Key: "class",
+ Val: "broken-code-point",
+ }); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.Text(fmt.Sprintf("<%X>", bs)); err != nil {
+ return err
+ }
+
+ return e.PassthroughHTMLStreamer.EndTag("span")
+}
+
+func (e *escapeStreamer) ambiguousRune(r, c rune) error {
+ e.escaped.Escaped = true
+ e.escaped.HasAmbiguous = true
+
+ if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{
+ Key: "class",
+ Val: "ambiguous-code-point",
+ }, html.Attribute{
+ Key: "data-tooltip-content",
+ Val: e.locale.TrString("repo.ambiguous_character", r, c),
+ }); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{
+ Key: "class",
+ Val: "char",
+ }); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.Text(string(r)); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.EndTag("span"); err != nil {
+ return err
+ }
+
+ return e.PassthroughHTMLStreamer.EndTag("span")
+}
+
+func (e *escapeStreamer) invisibleRune(r rune) error {
+ e.escaped.Escaped = true
+ e.escaped.HasInvisible = true
+
+ if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{
+ Key: "class",
+ Val: "escaped-code-point",
+ }, html.Attribute{
+ Key: "data-escaped",
+ Val: fmt.Sprintf("[U+%04X]", r),
+ }); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{
+ Key: "class",
+ Val: "char",
+ }); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.Text(string(r)); err != nil {
+ return err
+ }
+ if err := e.PassthroughHTMLStreamer.EndTag("span"); err != nil {
+ return err
+ }
+
+ return e.PassthroughHTMLStreamer.EndTag("span")
+}
+
+type runeCountType struct {
+ numBasicRunes int
+ numNonConfusingNonBasicRunes int
+ numAmbiguousRunes int
+ numInvisibleRunes int
+ numBrokenRunes int
+}
+
+func (counts runeCountType) needsEscape() bool {
+ if counts.numBrokenRunes > 0 {
+ return true
+ }
+ if counts.numBasicRunes == 0 &&
+ counts.numNonConfusingNonBasicRunes > 0 {
+ return false
+ }
+ return counts.numAmbiguousRunes > 0 || counts.numInvisibleRunes > 0
+}
+
+type runeType int
+
+const (
+ basicASCIIRuneType runeType = iota // <- This is technically deadcode but its self-documenting so it should stay
+ brokenRuneType
+ nonBasicASCIIRuneType
+ ambiguousRuneType
+ invisibleRuneType
+)
+
+func (e *escapeStreamer) runeTypes(runes ...rune) (types []runeType, confusables []rune, runeCounts runeCountType) {
+ types = make([]runeType, len(runes))
+ for i, r := range runes {
+ var confusable rune
+ switch {
+ case r == utf8.RuneError:
+ types[i] = brokenRuneType
+ runeCounts.numBrokenRunes++
+ case r == ' ' || r == '\t' || r == '\n':
+ runeCounts.numBasicRunes++
+ case e.allowed[r]:
+ if r > 0x7e || r < 0x20 {
+ types[i] = nonBasicASCIIRuneType
+ runeCounts.numNonConfusingNonBasicRunes++
+ } else {
+ runeCounts.numBasicRunes++
+ }
+ case unicode.Is(InvisibleRanges, r):
+ types[i] = invisibleRuneType
+ runeCounts.numInvisibleRunes++
+ case unicode.IsControl(r):
+ types[i] = invisibleRuneType
+ runeCounts.numInvisibleRunes++
+ case isAmbiguous(r, &confusable, e.ambiguousTables...):
+ confusables = append(confusables, confusable)
+ types[i] = ambiguousRuneType
+ runeCounts.numAmbiguousRunes++
+ case r > 0x7e || r < 0x20:
+ types[i] = nonBasicASCIIRuneType
+ runeCounts.numNonConfusingNonBasicRunes++
+ default:
+ runeCounts.numBasicRunes++
+ }
+ }
+ return types, confusables, runeCounts
+}
diff --git a/modules/charset/escape_test.go b/modules/charset/escape_test.go
new file mode 100644
index 0000000..2ca76f8
--- /dev/null
+++ b/modules/charset/escape_test.go
@@ -0,0 +1,194 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testContext = escapeContext("test")
+
+type escapeControlTest struct {
+ name string
+ text string
+ status EscapeStatus
+ result string
+}
+
+var escapeControlTests = []escapeControlTest{
+ {
+ name: "<empty>",
+ },
+ {
+ name: "single line western",
+ text: "single line western",
+ result: "single line western",
+ status: EscapeStatus{},
+ },
+ {
+ name: "multi line western",
+ text: "single line western\nmulti line western\n",
+ result: "single line western\nmulti line western\n",
+ status: EscapeStatus{},
+ },
+ {
+ name: "multi line western non-breaking space",
+ text: "single line western\nmulti line western\n",
+ result: `single line<span class="escaped-code-point" data-escaped="[U+00A0]"><span class="char"> </span></span>western` + "\n" + `multi line<span class="escaped-code-point" data-escaped="[U+00A0]"><span class="char"> </span></span>western` + "\n",
+ status: EscapeStatus{Escaped: true, HasInvisible: true},
+ },
+ {
+ name: "mixed scripts: western + japanese",
+ text: "日属秘ãžã—ã¡ã‚…。Then some western.",
+ result: "日属秘ãžã—ã¡ã‚…。Then some western.",
+ status: EscapeStatus{},
+ },
+ {
+ name: "japanese",
+ text: "日属秘ãžã—ã¡ã‚…。",
+ result: "日属秘ãžã—ã¡ã‚…。",
+ status: EscapeStatus{},
+ },
+ {
+ name: "hebrew",
+ text: "עד תקופת יוון העתיקה ×”×™×” העיסוק במתמטיקה תכליתי בלבד: ×”×™× ×©×™×ž×©×” ×›×וסף של נוסח×ות לחישוב קרקע, ×וכלוסין וכו'. פריצת הדרך של היווני×, פרט ×œ×ª×¨×•×ž×•×ª×™×”× ×”×’×“×•×œ×•×ª לידע המתמטי, הייתה בלימוד המתמטיקה כשלעצמה, מתוקף ערכה הרוחני. ×™×—×¡× ×©×œ חלק ×ž×”×™×•×•× ×™× ×”×§×“×ž×•× ×™× ×œ×ž×ª×ž×˜×™×§×” ×”×™×” דתי - למשל, הכת ש×סף סביבו פיתגורס ×”×מינה ×›×™ המתמטיקה ×”×™× ×”×‘×¡×™×¡ לכל הדברי×. ×”×™×•×•× ×™× × ×—×©×‘×™× ×œ×™×•×¦×¨×™ מושג ההוכחה המתמטית, וכן לר××©×•× ×™× ×©×¢×¡×§×• במתמטיקה ×œ×©× ×¢×¦×ž×”, כלומר ×›×ª×—×•× ×ž×—×§×¨×™ עיוני ומופשט ×•×œ× ×¨×§ כעזר שימושי. ×¢× ×–×ת, לצדה",
+ result: `עד תקופת <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">×™</span></span><span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ו</span></span><span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ו</span></span><span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ן</span></span> העתיקה ×”×™×” העיסוק במתמטיקה תכליתי בלבד: ×”×™× ×©×™×ž×©×” ×›×וסף של נוסח×ות לחישוב קרקע, ×וכלוסין וכו&#39;. פריצת הדרך של היווני×, פרט ×œ×ª×¨×•×ž×•×ª×™×”× ×”×’×“×•×œ×•×ª לידע המתמטי, הייתה בלימוד המתמטיקה כשלעצמה, מתוקף ערכה הרוחני. ×™×—×¡× ×©×œ חלק ×ž×”×™×•×•× ×™× ×”×§×“×ž×•× ×™× ×œ×ž×ª×ž×˜×™×§×” ×”×™×” דתי - למשל, הכת ש×סף סביבו פיתגורס ×”×מינה ×›×™ המתמטיקה ×”×™× ×”×‘×¡×™×¡ לכל הדברי×. ×”×™×•×•× ×™× × ×—×©×‘×™× ×œ×™×•×¦×¨×™ מושג ההוכחה המתמטית, וכן לר××©×•× ×™× ×©×¢×¡×§×• במתמטיקה ×œ×©× ×¢×¦×ž×”, כלומר ×›×ª×—×•× ×ž×—×§×¨×™ עיוני ומופשט ×•×œ× ×¨×§ כעזר שימושי. ×¢× ×–×ת, לצדה`,
+ status: EscapeStatus{Escaped: true, HasAmbiguous: true},
+ },
+ {
+ name: "more hebrew",
+ text: `בתקופה מ×וחרת יותר, השתמשו ×”×™×•×•× ×™× ×‘×©×™×˜×ª סימון מתקדמת יותר, שבה הוצגו ×”×ž×¡×¤×¨×™× ×œ×¤×™ 22 ×ותיות ×”×לפבית היווני. לסימון ×”×ž×¡×¤×¨×™× ×‘×™×Ÿ 1 ל-9 נקבעו תשע ×”×ותיות הר×שונות, בתוספת גרש ( ' ) בצד ימין של ×”×ות, למעלה; תשע ×”×ותיות הב×ות ייצגו ×ת העשרות מ-10 עד 90, והב×ות ×ת המ×ות. לסימון הספרות בין 1000 ל-900,000, השתמשו ×”×™×•×•× ×™× ×‘×ותן ×ותיות, ×ך הוסיפו ל×ותיות ×ת הגרש ×“×•×•×§× ×ž×¦×“ שמ×ל של ×”×ותיות, למטה. ממיליון ומעלה, כנר××” השתמשו ×”×™×•×•× ×™× ×‘×©× ×™ ×ª×’×™× ×‘×ž×§×•× ×חד.
+
+ המתמטיק××™ הבולט הר×שון ביוון העתיקה, ויש ×”××•×ž×¨×™× ×‘×ª×•×œ×“×•×ª ×”×נושות, ×”×•× ×ª×לס (624 לפנה"ס - 546 לפנה"ס בקירוב).[1] ×œ× ×™×”×™×” ×–×” משולל יסוד להניח ×©×”×•× ×”××“× ×”×¨×שון שהוכיח משפט מתמטי, ×•×œ× ×¨×§ גילה ×ותו. ת×לס הוכיח ×©×™×©×¨×™× ×ž×§×‘×™×œ×™× ×—×•×ª×›×™× ×ž×¦×“ ×חד של שוקי זווית ×§×˜×¢×™× ×‘×¢×œ×™ ×™×—×¡×™× ×©×•×•×™× (משפט ת×לס הר×שון), שהזווית המונחת על קוטר במעגל ×”×™× ×–×•×•×™×ª ישרה (משפט ת×לס השני), שהקוטר מחלק ×ת המעגל לשני ×—×œ×§×™× ×©×•×•×™×, ושזוויות הבסיס במשולש שווה-×©×•×§×™×™× ×©×•×•×ª זו לזו. מיוחסות לו ×’× ×©×™×˜×•×ª למדידת גובהן של הפירמידות בעזרת מדידת צילן ולקביעת מיקומה של ספינה הנר×ית מן החוף.
+
+ ×‘×©× ×™× 582 לפנה"ס עד 496 לפנה"ס, בקירוב, ×—×™ מתמטיק××™ חשוב במיוחד - פיתגורס. המקורות הר××©×•× ×™×™× ×¢×œ×™×• מועטי×, ×•×”×”×™×¡×˜×•×¨×™×•× ×™× ×ž×ª×§×©×™× ×œ×”×¤×¨×™×“ ×ת העובדות משכבת המסתורין וה×גדות שנקשרו בו. ידוע שסביבו התקבצה ×”×סכולה הפיתגור×ית מעין כת פסבדו-מתמטית שה×מינה ש"הכל מספר", ×ו ליתר דיוק הכל ניתן לכימות, וייחסה ×œ×ž×¡×¤×¨×™× ×ž×©×ž×¢×•×™×•×ª מיסטיות. ככל הנר××” הפיתגור××™× ×™×“×¢×• לבנות ×ת ×”×’×•×¤×™× ×”×פלטוניי×, הכירו ×ת הממוצע ×”×ריתמטי, הממוצע ×”×’×ומטרי והממוצע ההרמוני והגיעו ×œ×”×™×©×’×™× ×—×©×•×‘×™× × ×•×¡×¤×™×. ניתן לומר שהפיתגור××™× ×’×™×œ×• ×ת היותו של השורש הריבועי של 2, ×©×”×•× ×’× ×”×לכסון בריבוע ש×ורך צלעותיו 1, ××™ רציונלי, ×ך ×ª×’×œ×™×ª× ×”×™×™×ª×” למעשה רק ×©×”×§×˜×¢×™× "חסרי מידה משותפת", ומושג המספר ×”××™ רציונלי מ×וחר יותר.[2] ×זכור ר×שון ×œ×§×™×•×ž× ×©×œ ×§×˜×¢×™× ×—×¡×¨×™ מידה משותפת מופיע בדי×לוג "ת×יטיטוס" של ×פלטון, ×ך רעיון ×–×” ×”×™×” מוכר עוד ×§×•×“× ×œ×›×Ÿ, במ××” החמישית לפנה"ס להיפ×סוס, בן ×”×סכולה הפיתגור×ית, ו×ולי לפיתגורס עצמו.[3]`,
+ result: `בתקופה מ×וחרת יותר, השתמשו ×”×™×•×•× ×™× ×‘×©×™×˜×ª סימון מתקדמת יותר, שבה הוצגו ×”×ž×¡×¤×¨×™× ×œ×¤×™ 22 ×ותיות ×”×לפבית היווני. לסימון ×”×ž×¡×¤×¨×™× ×‘×™×Ÿ 1 ל-9 נקבעו תשע ×”×ותיות הר×שונות, בתוספת גרש ( &#39; ) בצד ימין של ×”×ות, למעלה; תשע ×”×ותיות הב×ות ייצגו ×ת העשרות מ-10 עד 90, והב×ות ×ת המ×ות. לסימון הספרות בין 1000 ל-900,000, השתמשו ×”×™×•×•× ×™× ×‘×ותן ×ותיות, ×ך הוסיפו ל×ותיות ×ת הגרש ×“×•×•×§× ×ž×¦×“ שמ×ל של ×”×ותיות, למטה. ממיליון ומעלה, כנר××” השתמשו ×”×™×•×•× ×™× ×‘×©× ×™ ×ª×’×™× ×‘×ž×§×•× ×חד.
+
+ המתמטיק××™ הבולט הר×שון ביוון העתיקה, ויש ×”××•×ž×¨×™× ×‘×ª×•×œ×“×•×ª ×”×נושות, ×”×•× ×ª×לס (624 לפנה&#34;<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ס</span></span> - 546 לפנה&#34;<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ס</span></span> בקירוב).[1] ×œ× ×™×”×™×” ×–×” משולל יסוד להניח ×©×”×•× ×”××“× ×”×¨×שון שהוכיח משפט מתמטי, ×•×œ× ×¨×§ גילה ×ותו. ת×לס הוכיח ×©×™×©×¨×™× ×ž×§×‘×™×œ×™× ×—×•×ª×›×™× ×ž×¦×“ ×חד של שוקי זווית ×§×˜×¢×™× ×‘×¢×œ×™ ×™×—×¡×™× ×©×•×•×™× (משפט ת×לס הר×שון), שהזווית המונחת על קוטר במעגל ×”×™× ×–×•×•×™×ª ישרה (משפט ת×לס השני), שהקוטר מחלק ×ת המעגל לשני ×—×œ×§×™× ×©×•×•×™×, ושזוויות הבסיס במשולש שווה-×©×•×§×™×™× ×©×•×•×ª זו לזו. מיוחסות לו ×’× ×©×™×˜×•×ª למדידת גובהן של הפירמידות בעזרת מדידת צילן ולקביעת מיקומה של ספינה הנר×ית מן החוף.
+
+ ×‘×©× ×™× 582 לפנה&#34;<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ס</span></span> עד 496 לפנה&#34;<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ס</span></span>, בקירוב, ×—×™ מתמטיק××™ חשוב במיוחד - פיתגורס. המקורות הר××©×•× ×™×™× ×¢×œ×™×• מועטי×, ×•×”×”×™×¡×˜×•×¨×™×•× ×™× ×ž×ª×§×©×™× ×œ×”×¤×¨×™×“ ×ת העובדות משכבת המסתורין וה×גדות שנקשרו בו. ידוע שסביבו התקבצה ×”×סכולה הפיתגור×ית מעין כת פסבדו-מתמטית שה×מינה ש&#34;הכל מספר&#34;, ×ו ליתר דיוק הכל ניתן לכימות, וייחסה ×œ×ž×¡×¤×¨×™× ×ž×©×ž×¢×•×™×•×ª מיסטיות. ככל הנר××” הפיתגור××™× ×™×“×¢×• לבנות ×ת ×”×’×•×¤×™× ×”×פלטוניי×, הכירו ×ת הממוצע ×”×ריתמטי, הממוצע ×”×’×ומטרי והממוצע ההרמוני והגיעו ×œ×”×™×©×’×™× ×—×©×•×‘×™× × ×•×¡×¤×™×. ניתן לומר שהפיתגור××™× ×’×™×œ×• ×ת היותו של השורש הריבועי של 2, ×©×”×•× ×’× ×”×לכסון בריבוע ש×ורך צלעותיו 1, ××™ רציונלי, ×ך ×ª×’×œ×™×ª× ×”×™×™×ª×” למעשה רק ×©×”×§×˜×¢×™× &#34;חסרי מידה משותפת&#34;, ומושג המספר ×”××™ רציונלי מ×וחר יותר.[2] ×זכור ר×שון ×œ×§×™×•×ž× ×©×œ ×§×˜×¢×™× ×—×¡×¨×™ מידה משותפת מופיע בדי×לוג &#34;ת×יטיטוס&#34; של ×פלטון, ×ך רעיון ×–×” ×”×™×” מוכר עוד ×§×•×“× ×œ×›×Ÿ, במ××” החמישית לפנה&#34;<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character"><span class="char">ס</span></span> להיפ×סוס, בן ×”×סכולה הפיתגור×ית, ו×ולי לפיתגורס עצמו.[3]`,
+ status: EscapeStatus{Escaped: true, HasAmbiguous: true},
+ },
+ {
+ name: "Mixed RTL+LTR",
+ text: `Many computer programs fail to display bidirectional text correctly.
+For example, the Hebrew name Sarah (שרה) is spelled: sin (ש) (which appears rightmost),
+then resh (ר), and finally heh (ה) (which should appear leftmost).`,
+ result: `Many computer programs fail to display bidirectional text correctly.
+For example, the Hebrew name Sarah (שרה) is spelled: sin (ש) (which appears rightmost),
+then resh (ר), and finally heh (ה) (which should appear leftmost).`,
+ status: EscapeStatus{},
+ },
+ {
+ name: "Mixed RTL+LTR+BIDI",
+ text: `Many computer programs fail to display bidirectional text correctly.
+ For example, the Hebrew name Sarah ` + "\u2067" + `שרה` + "\u2066\n" +
+ `sin (ש) (which appears rightmost), then resh (ר), and finally heh (ה) (which should appear leftmost).`,
+ result: `Many computer programs fail to display bidirectional text correctly.
+ For example, the Hebrew name Sarah ` + "\u2067" + `שרה` + "\u2066\n" +
+ `sin (ש) (which appears rightmost), then resh (ר), and finally heh (ה) (which should appear leftmost).`,
+ status: EscapeStatus{},
+ },
+ {
+ name: "Accented characters",
+ text: string([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}),
+ result: string([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}),
+ status: EscapeStatus{},
+ },
+ {
+ name: "Program",
+ text: "string([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba})",
+ result: "string([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba})",
+ status: EscapeStatus{},
+ },
+ {
+ name: "CVE testcase",
+ text: "if access_level != \"user\u202E \u2066// Check if admin\u2069 \u2066\" {",
+ result: `if access_level != &#34;user<span class="escaped-code-point" data-escaped="[U+202E]"><span class="char">` + "\u202e" + `</span></span> <span class="escaped-code-point" data-escaped="[U+2066]"><span class="char">` + "\u2066" + `</span></span>// Check if admin<span class="escaped-code-point" data-escaped="[U+2069]"><span class="char">` + "\u2069" + `</span></span> <span class="escaped-code-point" data-escaped="[U+2066]"><span class="char">` + "\u2066" + `</span></span>&#34; {`,
+ status: EscapeStatus{Escaped: true, HasInvisible: true},
+ },
+ {
+ name: "Mixed testcase with fail",
+ text: `Many computer programs fail to display bidirectional text correctly.
+ For example, the Hebrew name Sarah ` + "\u2067" + `שרה` + "\u2066\n" +
+ `sin (ש) (which appears rightmost), then resh (ר), and finally heh (ה) (which should appear leftmost).` +
+ "\nif access_level != \"user\u202E \u2066// Check if admin\u2069 \u2066\" {\n",
+ result: `Many computer programs fail to display bidirectional text correctly.
+ For example, the Hebrew name Sarah ` + "\u2067" + `שרה` + "\u2066\n" +
+ `sin (ש) (which appears rightmost), then resh (ר), and finally heh (ה) (which should appear leftmost).` +
+ "\n" + `if access_level != &#34;user<span class="escaped-code-point" data-escaped="[U+202E]"><span class="char">` + "\u202e" + `</span></span> <span class="escaped-code-point" data-escaped="[U+2066]"><span class="char">` + "\u2066" + `</span></span>// Check if admin<span class="escaped-code-point" data-escaped="[U+2069]"><span class="char">` + "\u2069" + `</span></span> <span class="escaped-code-point" data-escaped="[U+2066]"><span class="char">` + "\u2066" + `</span></span>&#34; {` + "\n",
+ status: EscapeStatus{Escaped: true, HasInvisible: true},
+ },
+ {
+ // UTF-8/16/32 all use the same codepoint for BOM
+ // Forgejo could read UTF-16/32 content and convert into UTF-8 internally then render it, so we only process UTF-8 internally
+ name: "UTF BOM",
+ text: "\xef\xbb\xbftest",
+ result: "\xef\xbb\xbftest",
+ status: EscapeStatus{},
+ },
+}
+
+func TestEscapeControlReader(t *testing.T) {
+ // add some control characters to the tests
+ tests := make([]escapeControlTest, 0, len(escapeControlTests)*3)
+ copy(tests, escapeControlTests)
+
+ // if there is a BOM, we should keep the BOM
+ addPrefix := func(prefix, s string) string {
+ if strings.HasPrefix(s, "\xef\xbb\xbf") {
+ return s[:3] + prefix + s[3:]
+ }
+ return prefix + s
+ }
+ for _, test := range escapeControlTests {
+ test.name += " (+Control)"
+ test.text = addPrefix("\u001E", test.text)
+ test.result = addPrefix(`<span class="escaped-code-point" data-escaped="[U+001E]"><span class="char">`+"\u001e"+`</span></span>`, test.result)
+ test.status.Escaped = true
+ test.status.HasInvisible = true
+ tests = append(tests, test)
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output := &strings.Builder{}
+ status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{}, testContext)
+ require.NoError(t, err)
+ assert.Equal(t, tt.status, *status)
+ assert.Equal(t, tt.result, output.String())
+ })
+ }
+}
+
+func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
+
+ _, out := EscapeControlHTML("a test", &translation.MockLocale{}, testContext)
+ assert.EqualValues(t, `a<span class="escaped-code-point" data-escaped="[U+00A0]"><span class="char"> </span></span>test`, out)
+ setting.UI.AmbiguousUnicodeDetection = false
+ _, out = EscapeControlHTML("a test", &translation.MockLocale{}, testContext)
+ assert.EqualValues(t, `a test`, out)
+}
+
+func TestAmbiguousUnicodeDetectionContext(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"test"})()
+
+ input := template.HTML("a test")
+
+ _, out := EscapeControlHTML(input, &translation.MockLocale{}, escapeContext("not-test"))
+ assert.EqualValues(t, `a<span class="escaped-code-point" data-escaped="[U+00A0]"><span class="char"> </span></span>test`, out)
+
+ _, out = EscapeControlHTML(input, &translation.MockLocale{}, testContext)
+ assert.EqualValues(t, input, out)
+}
diff --git a/modules/charset/htmlstream.go b/modules/charset/htmlstream.go
new file mode 100644
index 0000000..61f2912
--- /dev/null
+++ b/modules/charset/htmlstream.go
@@ -0,0 +1,200 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import (
+ "fmt"
+ "io"
+
+ "golang.org/x/net/html"
+)
+
+// HTMLStreamer represents a SAX-like interface for HTML
+type HTMLStreamer interface {
+ Error(err error) error
+ Doctype(data string) error
+ Comment(data string) error
+ StartTag(data string, attrs ...html.Attribute) error
+ SelfClosingTag(data string, attrs ...html.Attribute) error
+ EndTag(data string) error
+ Text(data string) error
+}
+
+// PassthroughHTMLStreamer is a passthrough streamer
+type PassthroughHTMLStreamer struct {
+ next HTMLStreamer
+}
+
+func NewPassthroughStreamer(next HTMLStreamer) *PassthroughHTMLStreamer {
+ return &PassthroughHTMLStreamer{next: next}
+}
+
+var _ (HTMLStreamer) = &PassthroughHTMLStreamer{}
+
+// Error tells the next streamer in line that there is an error
+func (p *PassthroughHTMLStreamer) Error(err error) error {
+ return p.next.Error(err)
+}
+
+// Doctype tells the next streamer what the doctype is
+func (p *PassthroughHTMLStreamer) Doctype(data string) error {
+ return p.next.Doctype(data)
+}
+
+// Comment tells the next streamer there is a comment
+func (p *PassthroughHTMLStreamer) Comment(data string) error {
+ return p.next.Comment(data)
+}
+
+// StartTag tells the next streamer there is a starting tag
+func (p *PassthroughHTMLStreamer) StartTag(data string, attrs ...html.Attribute) error {
+ return p.next.StartTag(data, attrs...)
+}
+
+// SelfClosingTag tells the next streamer there is a self-closing tag
+func (p *PassthroughHTMLStreamer) SelfClosingTag(data string, attrs ...html.Attribute) error {
+ return p.next.SelfClosingTag(data, attrs...)
+}
+
+// EndTag tells the next streamer there is a end tag
+func (p *PassthroughHTMLStreamer) EndTag(data string) error {
+ return p.next.EndTag(data)
+}
+
+// Text tells the next streamer there is a text
+func (p *PassthroughHTMLStreamer) Text(data string) error {
+ return p.next.Text(data)
+}
+
+// HTMLStreamWriter acts as a writing sink
+type HTMLStreamerWriter struct {
+ io.Writer
+ err error
+}
+
+// Write implements io.Writer
+func (h *HTMLStreamerWriter) Write(data []byte) (int, error) {
+ if h.err != nil {
+ return 0, h.err
+ }
+ return h.Writer.Write(data)
+}
+
+// Write implements io.StringWriter
+func (h *HTMLStreamerWriter) WriteString(data string) (int, error) {
+ if h.err != nil {
+ return 0, h.err
+ }
+ return h.Writer.Write([]byte(data))
+}
+
+// Error tells the next streamer in line that there is an error
+func (h *HTMLStreamerWriter) Error(err error) error {
+ if h.err == nil {
+ h.err = err
+ }
+ return h.err
+}
+
+// Doctype tells the next streamer what the doctype is
+func (h *HTMLStreamerWriter) Doctype(data string) error {
+ _, h.err = h.WriteString("<!DOCTYPE " + data + ">")
+ return h.err
+}
+
+// Comment tells the next streamer there is a comment
+func (h *HTMLStreamerWriter) Comment(data string) error {
+ _, h.err = h.WriteString("<!--" + data + "-->")
+ return h.err
+}
+
+// StartTag tells the next streamer there is a starting tag
+func (h *HTMLStreamerWriter) StartTag(data string, attrs ...html.Attribute) error {
+ return h.startTag(data, attrs, false)
+}
+
+// SelfClosingTag tells the next streamer there is a self-closing tag
+func (h *HTMLStreamerWriter) SelfClosingTag(data string, attrs ...html.Attribute) error {
+ return h.startTag(data, attrs, true)
+}
+
+func (h *HTMLStreamerWriter) startTag(data string, attrs []html.Attribute, selfclosing bool) error {
+ if _, h.err = h.WriteString("<" + data); h.err != nil {
+ return h.err
+ }
+ for _, attr := range attrs {
+ if _, h.err = h.WriteString(" " + attr.Key + "=\"" + html.EscapeString(attr.Val) + "\""); h.err != nil {
+ return h.err
+ }
+ }
+ if selfclosing {
+ if _, h.err = h.WriteString("/>"); h.err != nil {
+ return h.err
+ }
+ } else {
+ if _, h.err = h.WriteString(">"); h.err != nil {
+ return h.err
+ }
+ }
+ return h.err
+}
+
+// EndTag tells the next streamer there is a end tag
+func (h *HTMLStreamerWriter) EndTag(data string) error {
+ _, h.err = h.WriteString("</" + data + ">")
+ return h.err
+}
+
+// Text tells the next streamer there is a text
+func (h *HTMLStreamerWriter) Text(data string) error {
+ _, h.err = h.WriteString(html.EscapeString(data))
+ return h.err
+}
+
+// StreamHTML streams an html to a provided streamer
+func StreamHTML(source io.Reader, streamer HTMLStreamer) error {
+ tokenizer := html.NewTokenizer(source)
+ for {
+ tt := tokenizer.Next()
+ switch tt {
+ case html.ErrorToken:
+ if tokenizer.Err() != io.EOF {
+ return tokenizer.Err()
+ }
+ return nil
+ case html.DoctypeToken:
+ token := tokenizer.Token()
+ if err := streamer.Doctype(token.Data); err != nil {
+ return err
+ }
+ case html.CommentToken:
+ token := tokenizer.Token()
+ if err := streamer.Comment(token.Data); err != nil {
+ return err
+ }
+ case html.StartTagToken:
+ token := tokenizer.Token()
+ if err := streamer.StartTag(token.Data, token.Attr...); err != nil {
+ return err
+ }
+ case html.SelfClosingTagToken:
+ token := tokenizer.Token()
+ if err := streamer.StartTag(token.Data, token.Attr...); err != nil {
+ return err
+ }
+ case html.EndTagToken:
+ token := tokenizer.Token()
+ if err := streamer.EndTag(token.Data); err != nil {
+ return err
+ }
+ case html.TextToken:
+ token := tokenizer.Token()
+ if err := streamer.Text(token.Data); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("unknown type of token: %d", tt)
+ }
+ }
+}
diff --git a/modules/charset/invisible/generate.go b/modules/charset/invisible/generate.go
new file mode 100644
index 0000000..bd57dd6
--- /dev/null
+++ b/modules/charset/invisible/generate.go
@@ -0,0 +1,121 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "go/format"
+ "os"
+ "text/template"
+
+ "golang.org/x/text/unicode/rangetable"
+)
+
+// InvisibleRunes these are runes that vscode has assigned to be invisible
+// See https://github.com/hediet/vscode-unicode-data
+var InvisibleRunes = []rune{
+ 9, 10, 11, 12, 13, 32, 127, 160, 173, 847, 1564, 4447, 4448, 6068, 6069, 6155, 6156, 6157, 6158, 7355, 7356, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8203, 8204, 8205, 8206, 8207, 8234, 8235, 8236, 8237, 8238, 8239, 8287, 8288, 8289, 8290, 8291, 8292, 8293, 8294, 8295, 8296, 8297, 8298, 8299, 8300, 8301, 8302, 8303, 10240, 12288, 12644, 65024, 65025, 65026, 65027, 65028, 65029, 65030, 65031, 65032, 65033, 65034, 65035, 65036, 65037, 65038, 65039, 65279, 65440, 65520, 65521, 65522, 65523, 65524, 65525, 65526, 65527, 65528, 65532, 78844, 119155, 119156, 119157, 119158, 119159, 119160, 119161, 119162, 917504, 917505, 917506, 917507, 917508, 917509, 917510, 917511, 917512, 917513, 917514, 917515, 917516, 917517, 917518, 917519, 917520, 917521, 917522, 917523, 917524, 917525, 917526, 917527, 917528, 917529, 917530, 917531, 917532, 917533, 917534, 917535, 917536, 917537, 917538, 917539, 917540, 917541, 917542, 917543, 917544, 917545, 917546, 917547, 917548, 917549, 917550, 917551, 917552, 917553, 917554, 917555, 917556, 917557, 917558, 917559, 917560, 917561, 917562, 917563, 917564, 917565, 917566, 917567, 917568, 917569, 917570, 917571, 917572, 917573, 917574, 917575, 917576, 917577, 917578, 917579, 917580, 917581, 917582, 917583, 917584, 917585, 917586, 917587, 917588, 917589, 917590, 917591, 917592, 917593, 917594, 917595, 917596, 917597, 917598, 917599, 917600, 917601, 917602, 917603, 917604, 917605, 917606, 917607, 917608, 917609, 917610, 917611, 917612, 917613, 917614, 917615, 917616, 917617, 917618, 917619, 917620, 917621, 917622, 917623, 917624, 917625, 917626, 917627, 917628, 917629, 917630, 917631, 917760, 917761, 917762, 917763, 917764, 917765, 917766, 917767, 917768, 917769, 917770, 917771, 917772, 917773, 917774, 917775, 917776, 917777, 917778, 917779, 917780, 917781, 917782, 917783, 917784, 917785, 917786, 917787, 917788, 917789, 917790, 917791, 917792, 917793, 917794, 917795, 917796, 917797, 917798, 917799, 917800, 917801, 917802, 917803, 917804, 917805, 917806, 917807, 917808, 917809, 917810, 917811, 917812, 917813, 917814, 917815, 917816, 917817, 917818, 917819, 917820, 917821, 917822, 917823, 917824, 917825, 917826, 917827, 917828, 917829, 917830, 917831, 917832, 917833, 917834, 917835, 917836, 917837, 917838, 917839, 917840, 917841, 917842, 917843, 917844, 917845, 917846, 917847, 917848, 917849, 917850, 917851, 917852, 917853, 917854, 917855, 917856, 917857, 917858, 917859, 917860, 917861, 917862, 917863, 917864, 917865, 917866, 917867, 917868, 917869, 917870, 917871, 917872, 917873, 917874, 917875, 917876, 917877, 917878, 917879, 917880, 917881, 917882, 917883, 917884, 917885, 917886, 917887, 917888, 917889, 917890, 917891, 917892, 917893, 917894, 917895, 917896, 917897, 917898, 917899, 917900, 917901, 917902, 917903, 917904, 917905, 917906, 917907, 917908, 917909, 917910, 917911, 917912, 917913, 917914, 917915, 917916, 917917, 917918, 917919, 917920, 917921, 917922, 917923, 917924, 917925, 917926, 917927, 917928, 917929, 917930, 917931, 917932, 917933, 917934, 917935, 917936, 917937, 917938, 917939, 917940, 917941, 917942, 917943, 917944, 917945, 917946, 917947, 917948, 917949, 917950, 917951, 917952, 917953, 917954, 917955, 917956, 917957, 917958, 917959, 917960, 917961, 917962, 917963, 917964, 917965, 917966, 917967, 917968, 917969, 917970, 917971, 917972, 917973, 917974, 917975, 917976, 917977, 917978, 917979, 917980, 917981, 917982, 917983, 917984, 917985, 917986, 917987, 917988, 917989, 917990, 917991, 917992, 917993, 917994, 917995, 917996, 917997, 917998, 917999,
+}
+
+var verbose bool
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, `%s: Generate InvisibleRunesRange
+
+Usage: %[1]s [-v] [-o output.go]
+`, os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ output := ""
+ flag.BoolVar(&verbose, "v", false, "verbose output")
+ flag.StringVar(&output, "o", "invisible_gen.go", "file to output to")
+ flag.Parse()
+
+ // First we filter the runes to remove
+ // <space><tab><newline>
+ filtered := make([]rune, 0, len(InvisibleRunes))
+ for _, r := range InvisibleRunes {
+ if r == ' ' || r == '\t' || r == '\n' {
+ continue
+ }
+ filtered = append(filtered, r)
+ }
+
+ table := rangetable.New(filtered...)
+ if err := runTemplate(generatorTemplate, output, table); err != nil {
+ fatalf("Unable to run template: %v", err)
+ }
+}
+
+func runTemplate(t *template.Template, filename string, data any) error {
+ buf := bytes.NewBuffer(nil)
+ if err := t.Execute(buf, data); err != nil {
+ return fmt.Errorf("unable to execute template: %w", err)
+ }
+ bs, err := format.Source(buf.Bytes())
+ if err != nil {
+ verbosef("Bad source:\n%s", buf.String())
+ return fmt.Errorf("unable to format source: %w", err)
+ }
+
+ old, err := os.ReadFile(filename)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to read old file %s because %w", filename, err)
+ } else if err == nil {
+ if bytes.Equal(bs, old) {
+ // files are the same don't rewrite it.
+ return nil
+ }
+ }
+
+ file, err := os.Create(filename)
+ if err != nil {
+ return fmt.Errorf("failed to create file %s because %w", filename, err)
+ }
+ defer file.Close()
+ _, err = file.Write(bs)
+ if err != nil {
+ return fmt.Errorf("unable to write generated source: %w", err)
+ }
+ return nil
+}
+
+var generatorTemplate = template.Must(template.New("invisibleTemplate").Parse(`// This file is generated by modules/charset/invisible/generate.go DO NOT EDIT
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+
+package charset
+
+import "unicode"
+
+var InvisibleRanges = &unicode.RangeTable{
+ R16: []unicode.Range16{
+{{range .R16 }} {Lo:{{.Lo}}, Hi:{{.Hi}}, Stride: {{.Stride}}},
+{{end}} },
+ R32: []unicode.Range32{
+{{range .R32}} {Lo:{{.Lo}}, Hi:{{.Hi}}, Stride: {{.Stride}}},
+{{end}} },
+ LatinOffset: {{.LatinOffset}},
+}
+`))
+
+func logf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
+
+func verbosef(format string, args ...any) {
+ if verbose {
+ logf(format, args...)
+ }
+}
+
+func fatalf(format string, args ...any) {
+ logf("fatal: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/modules/charset/invisible_gen.go b/modules/charset/invisible_gen.go
new file mode 100644
index 0000000..812f0e3
--- /dev/null
+++ b/modules/charset/invisible_gen.go
@@ -0,0 +1,36 @@
+// This file is generated by modules/charset/invisible/generate.go DO NOT EDIT
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package charset
+
+import "unicode"
+
+var InvisibleRanges = &unicode.RangeTable{
+ R16: []unicode.Range16{
+ {Lo: 11, Hi: 13, Stride: 1},
+ {Lo: 127, Hi: 160, Stride: 33},
+ {Lo: 173, Hi: 847, Stride: 674},
+ {Lo: 1564, Hi: 4447, Stride: 2883},
+ {Lo: 4448, Hi: 6068, Stride: 1620},
+ {Lo: 6069, Hi: 6155, Stride: 86},
+ {Lo: 6156, Hi: 6158, Stride: 1},
+ {Lo: 7355, Hi: 7356, Stride: 1},
+ {Lo: 8192, Hi: 8207, Stride: 1},
+ {Lo: 8234, Hi: 8239, Stride: 1},
+ {Lo: 8287, Hi: 8303, Stride: 1},
+ {Lo: 10240, Hi: 12288, Stride: 2048},
+ {Lo: 12644, Hi: 65024, Stride: 52380},
+ {Lo: 65025, Hi: 65039, Stride: 1},
+ {Lo: 65279, Hi: 65440, Stride: 161},
+ {Lo: 65520, Hi: 65528, Stride: 1},
+ {Lo: 65532, Hi: 65532, Stride: 1},
+ },
+ R32: []unicode.Range32{
+ {Lo: 78844, Hi: 119155, Stride: 40311},
+ {Lo: 119156, Hi: 119162, Stride: 1},
+ {Lo: 917504, Hi: 917631, Stride: 1},
+ {Lo: 917760, Hi: 917999, Stride: 1},
+ },
+ LatinOffset: 2,
+}
diff --git a/modules/container/filter.go b/modules/container/filter.go
new file mode 100644
index 0000000..37ec7c3
--- /dev/null
+++ b/modules/container/filter.go
@@ -0,0 +1,21 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import "slices"
+
+// FilterSlice ranges over the slice and calls include() for each element.
+// If the second returned value is true, the first returned value will be included in the resulting
+// slice (after deduplication).
+func FilterSlice[E any, T comparable](s []E, include func(E) (T, bool)) []T {
+ filtered := make([]T, 0, len(s)) // slice will be clipped before returning
+ seen := make(map[T]bool, len(s))
+ for i := range s {
+ if v, ok := include(s[i]); ok && !seen[v] {
+ filtered = append(filtered, v)
+ seen[v] = true
+ }
+ }
+ return slices.Clip(filtered)
+}
diff --git a/modules/container/filter_test.go b/modules/container/filter_test.go
new file mode 100644
index 0000000..ad304e5
--- /dev/null
+++ b/modules/container/filter_test.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFilterMapUnique(t *testing.T) {
+ result := FilterSlice([]int{
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
+ }, func(i int) (int, bool) {
+ switch i {
+ case 0:
+ return 0, true // included later
+ case 1:
+ return 0, true // duplicate of previous (should be ignored)
+ case 2:
+ return 2, false // not included
+ default:
+ return i, true
+ }
+ })
+ assert.Equal(t, []int{0, 3, 4, 5, 6, 7, 8, 9}, result)
+}
diff --git a/modules/container/set.go b/modules/container/set.go
new file mode 100644
index 0000000..1577998
--- /dev/null
+++ b/modules/container/set.go
@@ -0,0 +1,56 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+type Set[T comparable] map[T]struct{}
+
+// SetOf creates a set and adds the specified elements to it.
+func SetOf[T comparable](values ...T) Set[T] {
+ s := make(Set[T], len(values))
+ s.AddMultiple(values...)
+ return s
+}
+
+// Add adds the specified element to a set.
+// Returns true if the element is added; false if the element is already present.
+func (s Set[T]) Add(value T) bool {
+ if _, has := s[value]; !has {
+ s[value] = struct{}{}
+ return true
+ }
+ return false
+}
+
+// AddMultiple adds the specified elements to a set.
+func (s Set[T]) AddMultiple(values ...T) {
+ for _, value := range values {
+ s.Add(value)
+ }
+}
+
+// Contains determines whether a set contains the specified element.
+// Returns true if the set contains the specified element; otherwise, false.
+func (s Set[T]) Contains(value T) bool {
+ _, has := s[value]
+ return has
+}
+
+// Remove removes the specified element.
+// Returns true if the element is successfully found and removed; otherwise, false.
+func (s Set[T]) Remove(value T) bool {
+ if _, has := s[value]; has {
+ delete(s, value)
+ return true
+ }
+ return false
+}
+
+// Values gets a list of all elements in the set.
+func (s Set[T]) Values() []T {
+ keys := make([]T, 0, len(s))
+ for k := range s {
+ keys = append(keys, k)
+ }
+ return keys
+}
diff --git a/modules/container/set_test.go b/modules/container/set_test.go
new file mode 100644
index 0000000..1502236
--- /dev/null
+++ b/modules/container/set_test.go
@@ -0,0 +1,36 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSet(t *testing.T) {
+ s := make(Set[string])
+
+ assert.True(t, s.Add("key1"))
+ assert.False(t, s.Add("key1"))
+ assert.True(t, s.Add("key2"))
+
+ assert.True(t, s.Contains("key1"))
+ assert.True(t, s.Contains("key2"))
+ assert.False(t, s.Contains("key3"))
+
+ assert.True(t, s.Remove("key2"))
+ assert.False(t, s.Contains("key2"))
+
+ assert.False(t, s.Remove("key3"))
+
+ s.AddMultiple("key4", "key5")
+ assert.True(t, s.Contains("key4"))
+ assert.True(t, s.Contains("key5"))
+
+ s = SetOf("key6", "key7")
+ assert.False(t, s.Contains("key1"))
+ assert.True(t, s.Contains("key6"))
+ assert.True(t, s.Contains("key7"))
+}
diff --git a/modules/csv/csv.go b/modules/csv/csv.go
new file mode 100644
index 0000000..35c5d6a
--- /dev/null
+++ b/modules/csv/csv.go
@@ -0,0 +1,149 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package csv
+
+import (
+ "bytes"
+ stdcsv "encoding/csv"
+ "io"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ maxLines = 10
+ guessSampleSize = 1e4 // 10k
+)
+
+// CreateReader creates a csv.Reader with the given delimiter.
+func CreateReader(input io.Reader, delimiter rune) *stdcsv.Reader {
+ rd := stdcsv.NewReader(input)
+ rd.Comma = delimiter
+ if delimiter != '\t' && delimiter != ' ' {
+ // TrimLeadingSpace can't be true when delimiter is a tab or a space as the value for a column might be empty,
+ // thus would change `\t\t` to just `\t` or ` ` (two spaces) to just ` ` (single space)
+ rd.TrimLeadingSpace = true
+ }
+ return rd
+}
+
+// CreateReaderAndDetermineDelimiter tries to guess the field delimiter from the content and creates a csv.Reader.
+// Reads at most guessSampleSize bytes.
+func CreateReaderAndDetermineDelimiter(ctx *markup.RenderContext, rd io.Reader) (*stdcsv.Reader, error) {
+ data := make([]byte, guessSampleSize)
+ size, err := util.ReadAtMost(rd, data)
+ if err != nil {
+ return nil, err
+ }
+
+ return CreateReader(
+ io.MultiReader(bytes.NewReader(data[:size]), rd),
+ determineDelimiter(ctx, data[:size]),
+ ), nil
+}
+
+// determineDelimiter takes a RenderContext and if it isn't nil and the Filename has an extension that specifies the delimiter,
+// it is used as the delimiter. Otherwise we call guessDelimiter with the data passed
+func determineDelimiter(ctx *markup.RenderContext, data []byte) rune {
+ extension := ".csv"
+ if ctx != nil {
+ extension = strings.ToLower(filepath.Ext(ctx.RelativePath))
+ }
+
+ var delimiter rune
+ switch extension {
+ case ".tsv":
+ delimiter = '\t'
+ case ".psv":
+ delimiter = '|'
+ default:
+ delimiter = guessDelimiter(data)
+ }
+
+ return delimiter
+}
+
+// quoteRegexp follows the RFC-4180 CSV standard for when double-quotes are used to enclose fields, then a double-quote appearing inside a
+// field must be escaped by preceding it with another double quote. https://www.ietf.org/rfc/rfc4180.txt
+// This finds all quoted strings that have escaped quotes.
+var quoteRegexp = regexp.MustCompile(`"[^"]*"`)
+
+// removeQuotedStrings uses the quoteRegexp to remove all quoted strings so that we can reliably have each row on one line
+// (quoted strings often have new lines within the string)
+func removeQuotedString(text string) string {
+ return quoteRegexp.ReplaceAllLiteralString(text, "")
+}
+
+// guessDelimiter takes up to maxLines of the CSV text, iterates through the possible delimiters, and sees if the CSV Reader reads it without throwing any errors.
+// If more than one delimiter passes, the delimiter that results in the most columns is returned.
+func guessDelimiter(data []byte) rune {
+ delimiter := guessFromBeforeAfterQuotes(data)
+ if delimiter != 0 {
+ return delimiter
+ }
+
+ // Removes quoted values so we don't have columns with new lines in them
+ text := removeQuotedString(string(data))
+
+ // Make the text just be maxLines or less, ignoring truncated lines
+ lines := strings.SplitN(text, "\n", maxLines+1) // Will contain at least one line, and if there are more than MaxLines, the last item holds the rest of the lines
+ if len(lines) > maxLines {
+ // If the length of lines is > maxLines we know we have the max number of lines, trim it to maxLines
+ lines = lines[:maxLines]
+ } else if len(lines) > 1 && len(data) >= guessSampleSize {
+ // Even with data >= guessSampleSize, we don't have maxLines + 1 (no extra lines, must have really long lines)
+ // thus the last line is probably have a truncated line. Drop the last line if len(lines) > 1
+ lines = lines[:len(lines)-1]
+ }
+
+ // Put lines back together as a string
+ text = strings.Join(lines, "\n")
+
+ delimiters := []rune{',', '\t', ';', '|', '@'}
+ validDelim := delimiters[0]
+ validDelimColCount := 0
+ for _, delim := range delimiters {
+ csvReader := stdcsv.NewReader(strings.NewReader(text))
+ csvReader.Comma = delim
+ if rows, err := csvReader.ReadAll(); err == nil && len(rows) > 0 && len(rows[0]) > validDelimColCount {
+ validDelim = delim
+ validDelimColCount = len(rows[0])
+ }
+ }
+ return validDelim
+}
+
+// FormatError converts csv errors into readable messages.
+func FormatError(err error, locale translation.Locale) (string, error) {
+ if perr, ok := err.(*stdcsv.ParseError); ok {
+ if perr.Err == stdcsv.ErrFieldCount {
+ return locale.TrString("repo.error.csv.invalid_field_count", perr.Line), nil
+ }
+ return locale.TrString("repo.error.csv.unexpected", perr.Line, perr.Column), nil
+ }
+
+ return "", err
+}
+
+// Looks for possible delimiters right before or after (with spaces after the former) double quotes with closing quotes
+var beforeAfterQuotes = regexp.MustCompile(`([,@\t;|]{0,1}) *(?:"[^"]*")+([,@\t;|]{0,1})`)
+
+// guessFromBeforeAfterQuotes guesses the limiter by finding a double quote that has a valid delimiter before it and a closing quote,
+// or a double quote with a closing quote and a valid delimiter after it
+func guessFromBeforeAfterQuotes(data []byte) rune {
+ rs := beforeAfterQuotes.FindStringSubmatch(string(data)) // returns first match, or nil if none
+ if rs != nil {
+ if rs[1] != "" {
+ return rune(rs[1][0]) // delimiter found left of quoted string
+ } else if rs[2] != "" {
+ return rune(rs[2][0]) // delimiter found right of quoted string
+ }
+ }
+ return 0 // no match found
+}
diff --git a/modules/csv/csv_test.go b/modules/csv/csv_test.go
new file mode 100644
index 0000000..6ed6986
--- /dev/null
+++ b/modules/csv/csv_test.go
@@ -0,0 +1,590 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package csv
+
+import (
+ "bytes"
+ "encoding/csv"
+ "io"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateReader(t *testing.T) {
+ rd := CreateReader(bytes.NewReader([]byte{}), ',')
+ assert.Equal(t, ',', rd.Comma)
+}
+
+func decodeSlashes(t *testing.T, s string) string {
+ s = strings.ReplaceAll(s, "\n", "\\n")
+ s = strings.ReplaceAll(s, "\"", "\\\"")
+ decoded, err := strconv.Unquote(`"` + s + `"`)
+ require.NoError(t, err, "unable to decode string")
+ return decoded
+}
+
+func TestCreateReaderAndDetermineDelimiter(t *testing.T) {
+ cases := []struct {
+ csv string
+ expectedRows [][]string
+ expectedDelimiter rune
+ }{
+ // case 0 - semicolon delimited
+ {
+ csv: `a;b;c
+1;2;3
+4;5;6`,
+ expectedRows: [][]string{
+ {"a", "b", "c"},
+ {"1", "2", "3"},
+ {"4", "5", "6"},
+ },
+ expectedDelimiter: ';',
+ },
+ // case 1 - tab delimited with empty fields
+ {
+ csv: `col1 col2 col3
+a, b c
+ e f
+g h i
+j l
+m n,\t
+p q r
+ u
+v w x
+y\t\t
+ `,
+ expectedRows: [][]string{
+ {"col1", "col2", "col3"},
+ {"a,", "b", "c"},
+ {"", "e", "f"},
+ {"g", "h", "i"},
+ {"j", "", "l"},
+ {"m", "n,", ""},
+ {"p", "q", "r"},
+ {"", "", "u"},
+ {"v", "w", "x"},
+ {"y", "", ""},
+ {"", "", ""},
+ },
+ expectedDelimiter: '\t',
+ },
+ // case 2 - comma delimited with leading spaces
+ {
+ csv: ` col1,col2,col3
+ a, b, c
+d,e,f
+ ,h, i
+j, ,\x20
+ , , `,
+ expectedRows: [][]string{
+ {"col1", "col2", "col3"},
+ {"a", "b", "c"},
+ {"d", "e", "f"},
+ {"", "h", "i"},
+ {"j", "", ""},
+ {"", "", ""},
+ },
+ expectedDelimiter: ',',
+ },
+ }
+
+ for n, c := range cases {
+ rd, err := CreateReaderAndDetermineDelimiter(nil, strings.NewReader(decodeSlashes(t, c.csv)))
+ require.NoError(t, err, "case %d: should not throw error: %v\n", n, err)
+ assert.EqualValues(t, c.expectedDelimiter, rd.Comma, "case %d: delimiter should be '%c', got '%c'", n, c.expectedDelimiter, rd.Comma)
+ rows, err := rd.ReadAll()
+ require.NoError(t, err, "case %d: should not throw error: %v\n", n, err)
+ assert.EqualValues(t, c.expectedRows, rows, "case %d: rows should be equal", n)
+ }
+}
+
+type mockReader struct{}
+
+func (r *mockReader) Read(buf []byte) (int, error) {
+ return 0, io.ErrShortBuffer
+}
+
+func TestDetermineDelimiterShortBufferError(t *testing.T) {
+ rd, err := CreateReaderAndDetermineDelimiter(nil, &mockReader{})
+ require.Error(t, err, "CreateReaderAndDetermineDelimiter() should throw an error")
+ require.ErrorIs(t, err, io.ErrShortBuffer)
+ assert.Nil(t, rd, "CSV reader should be mnil")
+}
+
+func TestDetermineDelimiterReadAllError(t *testing.T) {
+ rd, err := CreateReaderAndDetermineDelimiter(nil, strings.NewReader(`col1,col2
+ a;b
+ c@e
+ f g
+ h|i
+ jkl`))
+ require.NoError(t, err, "CreateReaderAndDetermineDelimiter() shouldn't throw error")
+ assert.NotNil(t, rd, "CSV reader should not be mnil")
+ rows, err := rd.ReadAll()
+ require.Error(t, err, "RaadAll() should throw error")
+ require.ErrorIs(t, err, csv.ErrFieldCount)
+ assert.Empty(t, rows, "rows should be empty")
+}
+
+func TestDetermineDelimiter(t *testing.T) {
+ cases := []struct {
+ csv string
+ filename string
+ expectedDelimiter rune
+ }{
+ // case 0 - semicolon delmited
+ {
+ csv: "a",
+ filename: "test.csv",
+ expectedDelimiter: ',',
+ },
+ // case 1 - single column/row CSV
+ {
+ csv: "a",
+ filename: "",
+ expectedDelimiter: ',',
+ },
+ // case 2 - single column, single row CSV w/ tsv file extension (so is tabbed delimited)
+ {
+ csv: "1,2",
+ filename: "test.tsv",
+ expectedDelimiter: '\t',
+ },
+ // case 3 - two column, single row CSV w/ no filename, so will guess comma as delimiter
+ {
+ csv: "1,2",
+ filename: "",
+ expectedDelimiter: ',',
+ },
+ // case 4 - semi-colon delimited with csv extension
+ {
+ csv: "1;2",
+ filename: "test.csv",
+ expectedDelimiter: ';',
+ },
+ // case 5 - tabbed delimited with tsv extension
+ {
+ csv: "1\t2",
+ filename: "test.tsv",
+ expectedDelimiter: '\t',
+ },
+ // case 6 - tabbed delimited without any filename
+ {
+ csv: "1\t2",
+ filename: "",
+ expectedDelimiter: '\t',
+ },
+ // case 7 - tabs won't work, only commas as every row has same amount of commas
+ {
+ csv: "col1,col2\nfirst\tval,seconed\tval",
+ filename: "",
+ expectedDelimiter: ',',
+ },
+ // case 8 - While looks like comma delimited, has psv extension
+ {
+ csv: "1,2",
+ filename: "test.psv",
+ expectedDelimiter: '|',
+ },
+ // case 9 - pipe delmiited with no extension
+ {
+ csv: "1|2",
+ filename: "",
+ expectedDelimiter: '|',
+ },
+ // case 10 - semi-colon delimited with commas in values
+ {
+ csv: "1,2,3;4,5,6;7,8,9\na;b;c",
+ filename: "",
+ expectedDelimiter: ';',
+ },
+ // case 11 - semi-colon delimited with newline in content
+ {
+ csv: `"1,2,3,4";"a
+b";%
+c;d;#`,
+ filename: "",
+ expectedDelimiter: ';',
+ },
+ // case 12 - HTML as single value
+ {
+ csv: "<br/>",
+ filename: "",
+ expectedDelimiter: ',',
+ },
+ // case 13 - tab delimited with commas in values
+ {
+ csv: `name email note
+John Doe john@doe.com This,note,had,a,lot,of,commas,to,test,delimiters`,
+ filename: "",
+ expectedDelimiter: '\t',
+ },
+ }
+
+ for n, c := range cases {
+ delimiter := determineDelimiter(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: c.filename,
+ }, []byte(decodeSlashes(t, c.csv)))
+ assert.EqualValues(t, c.expectedDelimiter, delimiter, "case %d: delimiter should be equal, expected '%c' got '%c'", n, c.expectedDelimiter, delimiter)
+ }
+}
+
+func TestRemoveQuotedString(t *testing.T) {
+ cases := []struct {
+ text string
+ expectedText string
+ }{
+ // case 0 - quoted text with escaped quotes in 1st column
+ {
+ text: `col1,col2,col3
+"quoted ""text"" with
+new lines
+in first column",b,c`,
+ expectedText: `col1,col2,col3
+,b,c`,
+ },
+ // case 1 - quoted text with escaped quotes in 2nd column
+ {
+ text: `col1,col2,col3
+a,"quoted ""text"" with
+new lines
+in second column",c`,
+ expectedText: `col1,col2,col3
+a,,c`,
+ },
+ // case 2 - quoted text with escaped quotes in last column
+ {
+ text: `col1,col2,col3
+a,b,"quoted ""text"" with
+new lines
+in last column"`,
+ expectedText: `col1,col2,col3
+a,b,`,
+ },
+ // case 3 - csv with lots of quotes
+ {
+ text: `a,"b",c,d,"e
+e
+e",f
+a,bb,c,d,ee ,"f
+f"
+a,b,"c ""
+c",d,e,f`,
+ expectedText: `a,,c,d,,f
+a,bb,c,d,ee ,
+a,b,,d,e,f`,
+ },
+ // case 4 - csv with pipes and quotes
+ {
+ text: `Col1 | Col2 | Col3
+abc | "Hello
+World"|123
+"de
+
+f" | 4.56 | 789`,
+ expectedText: `Col1 | Col2 | Col3
+abc | |123
+ | 4.56 | 789`,
+ },
+ }
+
+ for n, c := range cases {
+ modifiedText := removeQuotedString(decodeSlashes(t, c.text))
+ assert.EqualValues(t, c.expectedText, modifiedText, "case %d: modified text should be equal", n)
+ }
+}
+
+func TestGuessDelimiter(t *testing.T) {
+ cases := []struct {
+ csv string
+ expectedDelimiter rune
+ }{
+ // case 0 - single cell, comma delmited
+ {
+ csv: "a",
+ expectedDelimiter: ',',
+ },
+ // case 1 - two cells, comma delimited
+ {
+ csv: "1,2",
+ expectedDelimiter: ',',
+ },
+ // case 2 - semicolon delimited
+ {
+ csv: "1;2",
+ expectedDelimiter: ';',
+ },
+ // case 3 - tab delimited
+ {
+ csv: "1\t2",
+ expectedDelimiter: '\t',
+ },
+ // case 4 - pipe delimited
+ {
+ csv: "1|2",
+ expectedDelimiter: '|',
+ },
+ // case 5 - semicolon delimited with commas in text
+ {
+ csv: `1,2,3;4,5,6;7,8,9
+a;b;c`,
+ expectedDelimiter: ';',
+ },
+ // case 6 - semicolon delmited with commas in quoted text
+ {
+ csv: `"1,2,3,4";"a
+b"
+c;d`,
+ expectedDelimiter: ';',
+ },
+ // case 7 - HTML
+ {
+ csv: "<br/>",
+ expectedDelimiter: ',',
+ },
+ // case 8 - tab delimited with commas in value
+ {
+ csv: `name email note
+John Doe john@doe.com This,note,had,a,lot,of,commas,to,test,delimiters`,
+ expectedDelimiter: '\t',
+ },
+ // case 9 - tab delimited with new lines in values, commas in values
+ {
+ csv: `1 "some,""more
+""
+ quoted,
+text," a
+2 "some,
+quoted,\t
+ text," b
+3 "some,
+quoted,
+ text" c
+4 "some,
+quoted,
+text," d`,
+ expectedDelimiter: '\t',
+ },
+ // case 10 - semicolon delmited with quotes and semicolon in value
+ {
+ csv: `col1;col2
+"this has a literal "" in the text";"and an ; in the text"`,
+ expectedDelimiter: ';',
+ },
+ // case 11 - pipe delimited with quotes
+ {
+ csv: `Col1 | Col2 | Col3
+abc | "Hello
+World"|123
+"de
+|
+f" | 4.56 | 789`,
+ expectedDelimiter: '|',
+ },
+ // case 12 - a tab delimited 6 column CSV, but the values are not quoted and have lots of commas.
+ // In the previous bestScore algorithm, this would have picked comma as the delimiter, but now it should guess tab
+ {
+ csv: `c1 c2 c3 c4 c5 c6
+v,k,x,v ym,f,oa,qn,uqijh,n,s,wvygpo uj,kt,j,w,i,fvv,tm,f,ddt,b,mwt,e,t,teq,rd,p,a e,wfuae,t,h,q,im,ix,y h,mrlu,l,dz,ff,zi,af,emh ,gov,bmfelvb,axp,f,u,i,cni,x,z,v,sh,w,jo,,m,h
+k,ohf,pgr,tde,m,s te,ek,,v,,ic,kqc,dv,w,oi,j,w,gojjr,ug,,l,j,zl g,qziq,bcajx,zfow,ka,j,re,ohbc k,nzm,qm,ts,auf th,elb,lx,l,q,e,qf asbr,z,k,y,tltobga
+g,m,bu,el h,l,jwi,o,wge,fy,rure,c,g,lcxu,fxte,uns,cl,s,o,t,h,rsoy,f bq,s,uov,z,ikkhgyg,,sabs,c,hzue mc,b,,j,t,n sp,mn,,m,t,dysi,eq,pigb,rfa,z w,rfli,sg,,o,wjjjf,f,wxdzfk,x,t,p,zy,p,mg,r,l,h
+e,ewbkc,nugd,jj,sf,ih,i,n,jo,b,poem,kw,q,i,x,t,e,uug,k j,xm,sch,ux,h,,fb,f,pq,,mh,,f,v,,oba,w,h,v,eiz,yzd,o,a,c,e,dhp,q a,pbef,epc,k,rdpuw,cw k,j,e,d xf,dz,sviv,w,sqnzew,t,b v,yg,f,cq,ti,g,m,ta,hm,ym,ii,hxy,p,z,r,e,ga,sfs,r,p,l,aar,w,kox,j
+l,d,v,pp,q,j,bxip,w,i,im,qa,o e,o h,w,a,a,qzj,nt,qfn,ut,fvhu,ts hu,q,g,p,q,ofpje,fsqa,frp,p,vih,j,w,k,jx, ln,th,ka,l,b,vgk,rv,hkx rj,v,y,cwm,rao,e,l,wvr,ptc,lm,yg,u,k,i,b,zk,b,gv,fls
+velxtnhlyuysbnlchosqlhkozkdapjaueexjwrndwb nglvnv kqiv pbshwlmcexdzipopxjyrxhvjalwp pydvipwlkkpdvbtepahskwuornbsb qwbacgq
+l,y,u,bf,y,m,eals,n,cop,h,g,vs,jga,opt x,b,zwmn,hh,b,n,pdj,t,d px yn,vtd,u,y,b,ps,yo,qqnem,mxg,m,al,rd,c,k,d,q,f ilxdxa,m,y,,p,p,y,prgmg,q,n,etj,k,ns b,pl,z,jq,hk
+p,gc jn,mzr,bw sb,e,r,dy,ur,wzy,r,c,n,yglr,jbdu,r,pqk,k q,d,,,p,l,euhl,dc,rwh,t,tq,z,h,p,s,t,x,fugr,h wi,zxb,jcig,o,t,k mfh,ym,h,e,p,cnvx,uv,zx,x,pq,blt,v,r,u,tr,g,g,xt
+nri,p,,t,if,,y,ptlqq a,i w,ovli,um,w,f,re,k,sb,w,jy,zf i,g,p,q,mii,nr,jm,cc i,szl,k,eg,l,d ,ah,w,b,vh
+,,sh,wx,mn,xm,u,d,yy,u,t,m,j,s,b ogadq,g,y,y,i,h,ln,jda,g,cz,s,rv,r,s,s,le,r, y,nu,f,nagj o,h,,adfy,o,nf,ns,gvsvnub,k,b,xyz v,h,g,ef,y,gb c,x,cw,x,go,h,t,x,cu,u,qgrqzrcmn,kq,cd,g,rejp,zcq
+skxg,t,vay,d,wug,d,xg,sexc rt g,ag,mjq,fjnyji,iwa,m,ml,b,ua,b,qjxeoc be,s,sh,n,jbzxs,g,n,i,h,y,r,be,mfo,u,p cw,r,,u,zn,eg,r,yac,m,l,edkr,ha,x,g,b,c,tg,c j,ye,u,ejd,maj,ea,bm,u,iy`,
+ expectedDelimiter: '\t',
+ },
+ // case 13 - a CSV with more than 10 lines and since we only use the first 10 lines, it should still get the delimiter as semicolon
+ {
+ csv: `col1;col2;col3
+1;1;1
+2;2;2
+3;3;3
+4;4;4
+5;5;5
+6;6;6
+7;7;7
+8;8;8
+9;9;9
+10;10;10
+11 11 11
+12|12|12`,
+ expectedDelimiter: ';',
+ },
+ // case 14 - a really long single line (over 10k) that will get truncated, but since it has commas and semicolons (but more semicolons) it will pick semicolon
+ {
+ csv: strings.Repeat("a;b,c;", 1700),
+ expectedDelimiter: ';',
+ },
+ // case 15 - 2 lines that are well over 10k, but since the 2nd line is where this CSV will be truncated (10k sample), it will only use the first line, so semicolon will be picked
+ {
+ csv: "col1@col2@col3\na@b@" + strings.Repeat("c", 6000) + "\nd,e," + strings.Repeat("f", 4000),
+ expectedDelimiter: '@',
+ },
+ // case 16 - has all delimiters so should return comma
+ {
+ csv: `col1,col2;col3@col4|col5 col6
+a b|c@d;e,f`,
+ expectedDelimiter: ',',
+ },
+ // case 16 - nothing works (bad csv) so returns comma by default
+ {
+ csv: `col1,col2
+a;b
+c@e
+f g
+h|i
+jkl`,
+ expectedDelimiter: ',',
+ },
+ }
+
+ for n, c := range cases {
+ delimiter := guessDelimiter([]byte(decodeSlashes(t, c.csv)))
+ assert.EqualValues(t, c.expectedDelimiter, delimiter, "case %d: delimiter should be equal, expected '%c' got '%c'", n, c.expectedDelimiter, delimiter)
+ }
+}
+
+func TestGuessFromBeforeAfterQuotes(t *testing.T) {
+ cases := []struct {
+ csv string
+ expectedDelimiter rune
+ }{
+ // case 0 - tab delimited with new lines in values, commas in values
+ {
+ csv: `1 "some,""more
+""
+ quoted,
+text," a
+2 "some,
+quoted,\t
+ text," b
+3 "some,
+quoted,
+ text" c
+4 "some,
+quoted,
+text," d`,
+ expectedDelimiter: '\t',
+ },
+ // case 1 - semicolon delmited with quotes and semicolon in value
+ {
+ csv: `col1;col2
+"this has a literal "" in the text";"and an ; in the text"`,
+ expectedDelimiter: ';',
+ },
+ // case 2 - pipe delimited with quotes
+ {
+ csv: `Col1 | Col2 | Col3
+abc | "Hello
+World"|123
+"de
+|
+f" | 4.56 | 789`,
+ expectedDelimiter: '|',
+ },
+ // case 3 - a complicated quoted CSV that is semicolon delmiited
+ {
+ csv: `he; she
+"he said, ""hey!"""; "she said, ""hey back!"""
+but; "be"`,
+ expectedDelimiter: ';',
+ },
+ // case 4 - no delimiter should be found
+ {
+ csv: `a,b`,
+ expectedDelimiter: 0,
+ },
+ // case 5 - no limiter should be found
+ {
+ csv: `col1
+"he said, ""here I am"""`,
+ expectedDelimiter: 0,
+ },
+ // case 6 - delimiter before double quoted string with space
+ {
+ csv: `col1|col2
+a| "he said, ""here I am"""`,
+ expectedDelimiter: '|',
+ },
+ // case 7 - delimiter before double quoted string without space
+ {
+ csv: `col1|col2
+a|"he said, ""here I am"""`,
+ expectedDelimiter: '|',
+ },
+ // case 8 - delimiter after double quoted string with space
+ {
+ csv: `col1, col2
+"abc\n
+
+", def`,
+ expectedDelimiter: ',',
+ },
+ // case 9 - delimiter after double quoted string without space
+ {
+ csv: `col1,col2
+"abc\n
+
+",def`,
+ expectedDelimiter: ',',
+ },
+ }
+
+ for n, c := range cases {
+ delimiter := guessFromBeforeAfterQuotes([]byte(decodeSlashes(t, c.csv)))
+ assert.EqualValues(t, c.expectedDelimiter, delimiter, "case %d: delimiter should be equal, expected '%c' got '%c'", n, c.expectedDelimiter, delimiter)
+ }
+}
+
+func TestFormatError(t *testing.T) {
+ cases := []struct {
+ err error
+ expectedMessage string
+ expectsError bool
+ }{
+ {
+ err: &csv.ParseError{
+ Err: csv.ErrFieldCount,
+ },
+ expectedMessage: "repo.error.csv.invalid_field_count",
+ expectsError: false,
+ },
+ {
+ err: &csv.ParseError{
+ Err: csv.ErrBareQuote,
+ },
+ expectedMessage: "repo.error.csv.unexpected",
+ expectsError: false,
+ },
+ {
+ err: bytes.ErrTooLarge,
+ expectsError: true,
+ },
+ }
+
+ for n, c := range cases {
+ message, err := FormatError(c.err, &translation.MockLocale{})
+ if c.expectsError {
+ require.Error(t, err, "case %d: expected an error to be returned", n)
+ } else {
+ require.NoError(t, err, "case %d: no error was expected, got error: %v", n, err)
+ assert.EqualValues(t, c.expectedMessage, message, "case %d: messages should be equal, expected '%s' got '%s'", n, c.expectedMessage, message)
+ }
+ }
+}
diff --git a/modules/emoji/emoji.go b/modules/emoji/emoji.go
new file mode 100644
index 0000000..3d4ef85
--- /dev/null
+++ b/modules/emoji/emoji.go
@@ -0,0 +1,186 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Copyright 2015 Kenneth Shaw
+// SPDX-License-Identifier: MIT
+
+package emoji
+
+import (
+ "io"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// Gemoji is a set of emoji data.
+type Gemoji []Emoji
+
+// Emoji represents a single emoji and associated data.
+type Emoji struct {
+ Emoji string
+ Description string
+ Aliases []string
+ UnicodeVersion string
+ SkinTones bool
+}
+
+var (
+ // codeMap provides a map of the emoji unicode code to its emoji data.
+ codeMap map[string]int
+
+ // aliasMap provides a map of the alias to its emoji data.
+ aliasMap map[string]int
+
+ // emptyReplacer is the string replacer for emoji codes.
+ emptyReplacer *strings.Replacer
+
+ // codeReplacer is the string replacer for emoji codes.
+ codeReplacer *strings.Replacer
+
+ // aliasReplacer is the string replacer for emoji aliases.
+ aliasReplacer *strings.Replacer
+
+ once sync.Once
+)
+
+func loadMap() {
+ once.Do(func() {
+ // initialize
+ codeMap = make(map[string]int, len(GemojiData))
+ aliasMap = make(map[string]int, len(GemojiData))
+
+ // process emoji codes and aliases
+ codePairs := make([]string, 0)
+ emptyPairs := make([]string, 0)
+ aliasPairs := make([]string, 0)
+
+ // sort from largest to small so we match combined emoji first
+ sort.Slice(GemojiData, func(i, j int) bool {
+ return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji)
+ })
+
+ for i, e := range GemojiData {
+ if e.Emoji == "" || len(e.Aliases) == 0 {
+ continue
+ }
+
+ // setup codes
+ codeMap[e.Emoji] = i
+ codePairs = append(codePairs, e.Emoji, ":"+e.Aliases[0]+":")
+ emptyPairs = append(emptyPairs, e.Emoji, e.Emoji)
+
+ // setup aliases
+ for _, a := range e.Aliases {
+ if a == "" {
+ continue
+ }
+
+ aliasMap[a] = i
+ aliasPairs = append(aliasPairs, ":"+a+":", e.Emoji)
+ }
+ }
+
+ // create replacers
+ emptyReplacer = strings.NewReplacer(emptyPairs...)
+ codeReplacer = strings.NewReplacer(codePairs...)
+ aliasReplacer = strings.NewReplacer(aliasPairs...)
+ })
+}
+
+// FromCode retrieves the emoji data based on the provided unicode code (ie,
+// "\u2618" will return the Gemoji data for "shamrock").
+func FromCode(code string) *Emoji {
+ loadMap()
+ i, ok := codeMap[code]
+ if !ok {
+ return nil
+ }
+
+ return &GemojiData[i]
+}
+
+// FromAlias retrieves the emoji data based on the provided alias in the form
+// "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji
+// data for "shamrock").
+func FromAlias(alias string) *Emoji {
+ loadMap()
+ if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") {
+ alias = alias[1 : len(alias)-1]
+ }
+
+ i, ok := aliasMap[alias]
+ if !ok {
+ return nil
+ }
+
+ return &GemojiData[i]
+}
+
+// ReplaceCodes replaces all emoji codes with the first corresponding emoji
+// alias (in the form of ":alias:") (ie, "\u2618" will be converted to
+// ":shamrock:").
+func ReplaceCodes(s string) string {
+ loadMap()
+ return codeReplacer.Replace(s)
+}
+
+// ReplaceAliases replaces all aliases of the form ":alias:" with its
+// corresponding unicode value.
+func ReplaceAliases(s string) string {
+ loadMap()
+ return aliasReplacer.Replace(s)
+}
+
+type rememberSecondWriteWriter struct {
+ pos int
+ idx int
+ end int
+ writecount int
+}
+
+func (n *rememberSecondWriteWriter) Write(p []byte) (int, error) {
+ n.writecount++
+ if n.writecount == 2 {
+ n.idx = n.pos
+ n.end = n.pos + len(p)
+ n.pos += len(p)
+ return len(p), io.EOF
+ }
+ n.pos += len(p)
+ return len(p), nil
+}
+
+func (n *rememberSecondWriteWriter) WriteString(s string) (int, error) {
+ n.writecount++
+ if n.writecount == 2 {
+ n.idx = n.pos
+ n.end = n.pos + len(s)
+ n.pos += len(s)
+ return len(s), io.EOF
+ }
+ n.pos += len(s)
+ return len(s), nil
+}
+
+// FindEmojiSubmatchIndex returns index pair of longest emoji in a string
+func FindEmojiSubmatchIndex(s string) []int {
+ loadMap()
+ secondWriteWriter := rememberSecondWriteWriter{}
+
+ // A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but
+ // we can be lazy here.
+ //
+ // The implementation of strings.Replacer.WriteString is such that the first index of the emoji
+ // submatch is simply the second thing that is written to WriteString in the writer.
+ //
+ // Therefore we can simply take the index of the second write as our first emoji
+ //
+ // FIXME: just copy the trie implementation from strings.NewReplacer
+ _, _ = emptyReplacer.WriteString(&secondWriteWriter, s)
+
+ // if we wrote less than twice then we never "replaced"
+ if secondWriteWriter.writecount < 2 {
+ return nil
+ }
+
+ return []int{secondWriteWriter.idx, secondWriteWriter.end}
+}
diff --git a/modules/emoji/emoji_data.go b/modules/emoji/emoji_data.go
new file mode 100644
index 0000000..8d0ae0a
--- /dev/null
+++ b/modules/emoji/emoji_data.go
@@ -0,0 +1,3404 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package emoji
+
+// Code generated by build/generate-emoji.go. DO NOT EDIT.
+// Sourced from https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json
+var GemojiData = Gemoji{
+ {"\U0001f44d", "thumbs up", []string{"+1", "thumbsup"}, "6.0", true},
+ {"\U0001f44d\U0001f3ff", "thumbs up: Dark Skin Tone", []string{"+1_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44d\U0001f3fb", "thumbs up: Light Skin Tone", []string{"+1_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44d\U0001f3fe", "thumbs up: Medium-Dark Skin Tone", []string{"+1_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44d\U0001f3fc", "thumbs up: Medium-Light Skin Tone", []string{"+1_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44d\U0001f3fd", "thumbs up: Medium Skin Tone", []string{"+1_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f44e", "thumbs down", []string{"-1", "thumbsdown"}, "6.0", true},
+ {"\U0001f44e\U0001f3ff", "thumbs down: Dark Skin Tone", []string{"-1_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44e\U0001f3fb", "thumbs down: Light Skin Tone", []string{"-1_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44e\U0001f3fe", "thumbs down: Medium-Dark Skin Tone", []string{"-1_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44e\U0001f3fc", "thumbs down: Medium-Light Skin Tone", []string{"-1_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44e\U0001f3fd", "thumbs down: Medium Skin Tone", []string{"-1_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4af", "hundred points", []string{"100"}, "6.0", false},
+ {"\U0001f522", "input numbers", []string{"1234"}, "6.0", false},
+ {"\U0001f947", "1st place medal", []string{"1st_place_medal"}, "9.0", false},
+ {"\U0001f948", "2nd place medal", []string{"2nd_place_medal"}, "9.0", false},
+ {"\U0001f949", "3rd place medal", []string{"3rd_place_medal"}, "9.0", false},
+ {"\U0001f3b1", "pool 8 ball", []string{"8ball"}, "6.0", false},
+ {"\U0001f170\ufe0f", "A button (blood type)", []string{"a"}, "6.0", false},
+ {"\U0001f18e", "AB button (blood type)", []string{"ab"}, "6.0", false},
+ {"\U0001f9ee", "abacus", []string{"abacus"}, "11.0", false},
+ {"\U0001f524", "input latin letters", []string{"abc"}, "6.0", false},
+ {"\U0001f521", "input latin lowercase", []string{"abcd"}, "6.0", false},
+ {"\U0001f251", "Japanese “acceptable†button", []string{"accept"}, "6.0", false},
+ {"\U0001fa97", "accordion", []string{"accordion"}, "13.0", false},
+ {"\U0001fa79", "adhesive bandage", []string{"adhesive_bandage"}, "12.0", false},
+ {"\U0001f9d1", "person", []string{"adult"}, "11.0", true},
+ {"\U0001f9d1\U0001f3ff", "person: Dark Skin Tone", []string{"adult_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb", "person: Light Skin Tone", []string{"adult_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe", "person: Medium-Dark Skin Tone", []string{"adult_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc", "person: Medium-Light Skin Tone", []string{"adult_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd", "person: Medium Skin Tone", []string{"adult_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a1", "aerial tramway", []string{"aerial_tramway"}, "6.0", false},
+ {"\U0001f1e6\U0001f1eb", "flag: Afghanistan", []string{"afghanistan"}, "6.0", false},
+ {"\u2708\ufe0f", "airplane", []string{"airplane"}, "", false},
+ {"\U0001f1e6\U0001f1fd", "flag: Ã…land Islands", []string{"aland_islands"}, "6.0", false},
+ {"\u23f0", "alarm clock", []string{"alarm_clock"}, "6.0", false},
+ {"\U0001f1e6\U0001f1f1", "flag: Albania", []string{"albania"}, "6.0", false},
+ {"\u2697\ufe0f", "alembic", []string{"alembic"}, "4.1", false},
+ {"\U0001f1e9\U0001f1ff", "flag: Algeria", []string{"algeria"}, "6.0", false},
+ {"\U0001f47d", "alien", []string{"alien"}, "6.0", false},
+ {"\U0001f691", "ambulance", []string{"ambulance"}, "6.0", false},
+ {"\U0001f1e6\U0001f1f8", "flag: American Samoa", []string{"american_samoa"}, "6.0", false},
+ {"\U0001f3fa", "amphora", []string{"amphora"}, "8.0", false},
+ {"\U0001fac0", "anatomical heart", []string{"anatomical_heart"}, "13.0", false},
+ {"\u2693", "anchor", []string{"anchor"}, "4.1", false},
+ {"\U0001f1e6\U0001f1e9", "flag: Andorra", []string{"andorra"}, "6.0", false},
+ {"\U0001f47c", "baby angel", []string{"angel"}, "6.0", true},
+ {"\U0001f47c\U0001f3ff", "baby angel: Dark Skin Tone", []string{"angel_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f47c\U0001f3fb", "baby angel: Light Skin Tone", []string{"angel_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f47c\U0001f3fe", "baby angel: Medium-Dark Skin Tone", []string{"angel_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f47c\U0001f3fc", "baby angel: Medium-Light Skin Tone", []string{"angel_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f47c\U0001f3fd", "baby angel: Medium Skin Tone", []string{"angel_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4a2", "anger symbol", []string{"anger"}, "6.0", false},
+ {"\U0001f1e6\U0001f1f4", "flag: Angola", []string{"angola"}, "6.0", false},
+ {"\U0001f620", "angry face", []string{"angry"}, "6.0", false},
+ {"\U0001f1e6\U0001f1ee", "flag: Anguilla", []string{"anguilla"}, "6.0", false},
+ {"\U0001f627", "anguished face", []string{"anguished"}, "6.1", false},
+ {"\U0001f41c", "ant", []string{"ant"}, "6.0", false},
+ {"\U0001f1e6\U0001f1f6", "flag: Antarctica", []string{"antarctica"}, "6.0", false},
+ {"\U0001f1e6\U0001f1ec", "flag: Antigua & Barbuda", []string{"antigua_barbuda"}, "6.0", false},
+ {"\U0001f34e", "red apple", []string{"apple"}, "6.0", false},
+ {"\u2652", "Aquarius", []string{"aquarius"}, "", false},
+ {"\U0001f1e6\U0001f1f7", "flag: Argentina", []string{"argentina"}, "6.0", false},
+ {"\u2648", "Aries", []string{"aries"}, "", false},
+ {"\U0001f1e6\U0001f1f2", "flag: Armenia", []string{"armenia"}, "6.0", false},
+ {"\u25c0\ufe0f", "reverse button", []string{"arrow_backward"}, "", false},
+ {"\u23ec", "fast down button", []string{"arrow_double_down"}, "6.0", false},
+ {"\u23eb", "fast up button", []string{"arrow_double_up"}, "6.0", false},
+ {"\u2b07\ufe0f", "down arrow", []string{"arrow_down"}, "4.0", false},
+ {"\U0001f53d", "downwards button", []string{"arrow_down_small"}, "6.0", false},
+ {"\u25b6\ufe0f", "play button", []string{"arrow_forward"}, "", false},
+ {"\u2935\ufe0f", "right arrow curving down", []string{"arrow_heading_down"}, "", false},
+ {"\u2934\ufe0f", "right arrow curving up", []string{"arrow_heading_up"}, "", false},
+ {"\u2b05\ufe0f", "left arrow", []string{"arrow_left"}, "4.0", false},
+ {"\u2199\ufe0f", "down-left arrow", []string{"arrow_lower_left"}, "", false},
+ {"\u2198\ufe0f", "down-right arrow", []string{"arrow_lower_right"}, "", false},
+ {"\u27a1\ufe0f", "right arrow", []string{"arrow_right"}, "", false},
+ {"\u21aa\ufe0f", "left arrow curving right", []string{"arrow_right_hook"}, "", false},
+ {"\u2b06\ufe0f", "up arrow", []string{"arrow_up"}, "4.0", false},
+ {"\u2195\ufe0f", "up-down arrow", []string{"arrow_up_down"}, "", false},
+ {"\U0001f53c", "upwards button", []string{"arrow_up_small"}, "6.0", false},
+ {"\u2196\ufe0f", "up-left arrow", []string{"arrow_upper_left"}, "", false},
+ {"\u2197\ufe0f", "up-right arrow", []string{"arrow_upper_right"}, "", false},
+ {"\U0001f503", "clockwise vertical arrows", []string{"arrows_clockwise"}, "6.0", false},
+ {"\U0001f504", "counterclockwise arrows button", []string{"arrows_counterclockwise"}, "6.0", false},
+ {"\U0001f3a8", "artist palette", []string{"art"}, "6.0", false},
+ {"\U0001f69b", "articulated lorry", []string{"articulated_lorry"}, "6.0", false},
+ {"\U0001f6f0\ufe0f", "satellite", []string{"artificial_satellite"}, "7.0", false},
+ {"\U0001f9d1\u200d\U0001f3a8", "artist", []string{"artist"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f3a8", "artist: Dark Skin Tone", []string{"artist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f3a8", "artist: Light Skin Tone", []string{"artist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f3a8", "artist: Medium-Dark Skin Tone", []string{"artist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f3a8", "artist: Medium-Light Skin Tone", []string{"artist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f3a8", "artist: Medium Skin Tone", []string{"artist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1e6\U0001f1fc", "flag: Aruba", []string{"aruba"}, "6.0", false},
+ {"\U0001f1e6\U0001f1e8", "flag: Ascension Island", []string{"ascension_island"}, "11.0", false},
+ {"*\ufe0f\u20e3", "keycap: *", []string{"asterisk"}, "", false},
+ {"\U0001f632", "astonished face", []string{"astonished"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f680", "astronaut", []string{"astronaut"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f680", "astronaut: Dark Skin Tone", []string{"astronaut_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f680", "astronaut: Light Skin Tone", []string{"astronaut_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f680", "astronaut: Medium-Dark Skin Tone", []string{"astronaut_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f680", "astronaut: Medium-Light Skin Tone", []string{"astronaut_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f680", "astronaut: Medium Skin Tone", []string{"astronaut_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f45f", "running shoe", []string{"athletic_shoe"}, "6.0", false},
+ {"\U0001f3e7", "ATM sign", []string{"atm"}, "6.0", false},
+ {"\u269b\ufe0f", "atom symbol", []string{"atom_symbol"}, "4.1", false},
+ {"\U0001f1e6\U0001f1fa", "flag: Australia", []string{"australia"}, "6.0", false},
+ {"\U0001f1e6\U0001f1f9", "flag: Austria", []string{"austria"}, "6.0", false},
+ {"\U0001f6fa", "auto rickshaw", []string{"auto_rickshaw"}, "12.0", false},
+ {"\U0001f951", "avocado", []string{"avocado"}, "9.0", false},
+ {"\U0001fa93", "axe", []string{"axe"}, "12.0", false},
+ {"\U0001f1e6\U0001f1ff", "flag: Azerbaijan", []string{"azerbaijan"}, "6.0", false},
+ {"\U0001f171\ufe0f", "B button (blood type)", []string{"b"}, "6.0", false},
+ {"\U0001f476", "baby", []string{"baby"}, "6.0", true},
+ {"\U0001f476\U0001f3ff", "baby: Dark Skin Tone", []string{"baby_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f476\U0001f3fb", "baby: Light Skin Tone", []string{"baby_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f476\U0001f3fe", "baby: Medium-Dark Skin Tone", []string{"baby_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f476\U0001f3fc", "baby: Medium-Light Skin Tone", []string{"baby_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f476\U0001f3fd", "baby: Medium Skin Tone", []string{"baby_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f37c", "baby bottle", []string{"baby_bottle"}, "6.0", false},
+ {"\U0001f424", "baby chick", []string{"baby_chick"}, "6.0", false},
+ {"\U0001f6bc", "baby symbol", []string{"baby_symbol"}, "6.0", false},
+ {"\U0001f519", "BACK arrow", []string{"back"}, "6.0", false},
+ {"\U0001f953", "bacon", []string{"bacon"}, "9.0", false},
+ {"\U0001f9a1", "badger", []string{"badger"}, "11.0", false},
+ {"\U0001f3f8", "badminton", []string{"badminton"}, "8.0", false},
+ {"\U0001f96f", "bagel", []string{"bagel"}, "11.0", false},
+ {"\U0001f6c4", "baggage claim", []string{"baggage_claim"}, "6.0", false},
+ {"\U0001f956", "baguette bread", []string{"baguette_bread"}, "9.0", false},
+ {"\U0001f1e7\U0001f1f8", "flag: Bahamas", []string{"bahamas"}, "6.0", false},
+ {"\U0001f1e7\U0001f1ed", "flag: Bahrain", []string{"bahrain"}, "6.0", false},
+ {"\u2696\ufe0f", "balance scale", []string{"balance_scale"}, "4.1", false},
+ {"\U0001f468\u200d\U0001f9b2", "man: bald", []string{"bald_man"}, "11.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9b2", "man: bald: Dark Skin Tone", []string{"bald_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9b2", "man: bald: Light Skin Tone", []string{"bald_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9b2", "man: bald: Medium-Dark Skin Tone", []string{"bald_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9b2", "man: bald: Medium-Light Skin Tone", []string{"bald_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9b2", "man: bald: Medium Skin Tone", []string{"bald_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9b2", "woman: bald", []string{"bald_woman"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9b2", "woman: bald: Dark Skin Tone", []string{"bald_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9b2", "woman: bald: Light Skin Tone", []string{"bald_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9b2", "woman: bald: Medium-Dark Skin Tone", []string{"bald_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9b2", "woman: bald: Medium-Light Skin Tone", []string{"bald_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9b2", "woman: bald: Medium Skin Tone", []string{"bald_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fa70", "ballet shoes", []string{"ballet_shoes"}, "12.0", false},
+ {"\U0001f388", "balloon", []string{"balloon"}, "6.0", false},
+ {"\U0001f5f3\ufe0f", "ballot box with ballot", []string{"ballot_box"}, "7.0", false},
+ {"\u2611\ufe0f", "check box with check", []string{"ballot_box_with_check"}, "", false},
+ {"\U0001f38d", "pine decoration", []string{"bamboo"}, "6.0", false},
+ {"\U0001f34c", "banana", []string{"banana"}, "6.0", false},
+ {"\u203c\ufe0f", "double exclamation mark", []string{"bangbang"}, "", false},
+ {"\U0001f1e7\U0001f1e9", "flag: Bangladesh", []string{"bangladesh"}, "6.0", false},
+ {"\U0001fa95", "banjo", []string{"banjo"}, "12.0", false},
+ {"\U0001f3e6", "bank", []string{"bank"}, "6.0", false},
+ {"\U0001f4ca", "bar chart", []string{"bar_chart"}, "6.0", false},
+ {"\U0001f1e7\U0001f1e7", "flag: Barbados", []string{"barbados"}, "6.0", false},
+ {"\U0001f488", "barber pole", []string{"barber"}, "6.0", false},
+ {"\u26be", "baseball", []string{"baseball"}, "5.2", false},
+ {"\U0001f9fa", "basket", []string{"basket"}, "11.0", false},
+ {"\U0001f3c0", "basketball", []string{"basketball"}, "6.0", false},
+ {"\U0001f987", "bat", []string{"bat"}, "9.0", false},
+ {"\U0001f6c0", "person taking bath", []string{"bath"}, "6.0", true},
+ {"\U0001f6c0\U0001f3ff", "person taking bath: Dark Skin Tone", []string{"bath_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6c0\U0001f3fb", "person taking bath: Light Skin Tone", []string{"bath_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6c0\U0001f3fe", "person taking bath: Medium-Dark Skin Tone", []string{"bath_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6c0\U0001f3fc", "person taking bath: Medium-Light Skin Tone", []string{"bath_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6c0\U0001f3fd", "person taking bath: Medium Skin Tone", []string{"bath_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6c1", "bathtub", []string{"bathtub"}, "6.0", false},
+ {"\U0001f50b", "battery", []string{"battery"}, "6.0", false},
+ {"\U0001f3d6\ufe0f", "beach with umbrella", []string{"beach_umbrella"}, "7.0", false},
+ {"\U0001fad8", "beans", []string{"beans"}, "14.0", false},
+ {"\U0001f43b", "bear", []string{"bear"}, "6.0", false},
+ {"\U0001f9d4", "person: beard", []string{"bearded_person"}, "11.0", true},
+ {"\U0001f9d4\U0001f3ff", "person: beard: Dark Skin Tone", []string{"bearded_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fb", "person: beard: Light Skin Tone", []string{"bearded_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fe", "person: beard: Medium-Dark Skin Tone", []string{"bearded_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fc", "person: beard: Medium-Light Skin Tone", []string{"bearded_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fd", "person: beard: Medium Skin Tone", []string{"bearded_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ab", "beaver", []string{"beaver"}, "13.0", false},
+ {"\U0001f6cf\ufe0f", "bed", []string{"bed"}, "7.0", false},
+ {"\U0001f41d", "honeybee", []string{"bee", "honeybee"}, "6.0", false},
+ {"\U0001f37a", "beer mug", []string{"beer"}, "6.0", false},
+ {"\U0001f37b", "clinking beer mugs", []string{"beers"}, "6.0", false},
+ {"\U0001fab2", "beetle", []string{"beetle"}, "13.0", false},
+ {"\U0001f530", "Japanese symbol for beginner", []string{"beginner"}, "6.0", false},
+ {"\U0001f1e7\U0001f1fe", "flag: Belarus", []string{"belarus"}, "6.0", false},
+ {"\U0001f1e7\U0001f1ea", "flag: Belgium", []string{"belgium"}, "6.0", false},
+ {"\U0001f1e7\U0001f1ff", "flag: Belize", []string{"belize"}, "6.0", false},
+ {"\U0001f514", "bell", []string{"bell"}, "6.0", false},
+ {"\U0001fad1", "bell pepper", []string{"bell_pepper"}, "13.0", false},
+ {"\U0001f6ce\ufe0f", "bellhop bell", []string{"bellhop_bell"}, "7.0", false},
+ {"\U0001f1e7\U0001f1ef", "flag: Benin", []string{"benin"}, "6.0", false},
+ {"\U0001f371", "bento box", []string{"bento"}, "6.0", false},
+ {"\U0001f1e7\U0001f1f2", "flag: Bermuda", []string{"bermuda"}, "6.0", false},
+ {"\U0001f9c3", "beverage box", []string{"beverage_box"}, "12.0", false},
+ {"\U0001f1e7\U0001f1f9", "flag: Bhutan", []string{"bhutan"}, "6.0", false},
+ {"\U0001f6b4", "person biking", []string{"bicyclist"}, "6.0", true},
+ {"\U0001f6b4\U0001f3ff", "person biking: Dark Skin Tone", []string{"bicyclist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fb", "person biking: Light Skin Tone", []string{"bicyclist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fe", "person biking: Medium-Dark Skin Tone", []string{"bicyclist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fc", "person biking: Medium-Light Skin Tone", []string{"bicyclist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fd", "person biking: Medium Skin Tone", []string{"bicyclist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b2", "bicycle", []string{"bike"}, "6.0", false},
+ {"\U0001f6b4\u200d\u2642\ufe0f", "man biking", []string{"biking_man"}, "11.0", true},
+ {"\U0001f6b4\U0001f3ff\u200d\u2642\ufe0f", "man biking: Dark Skin Tone", []string{"biking_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fb\u200d\u2642\ufe0f", "man biking: Light Skin Tone", []string{"biking_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fe\u200d\u2642\ufe0f", "man biking: Medium-Dark Skin Tone", []string{"biking_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fc\u200d\u2642\ufe0f", "man biking: Medium-Light Skin Tone", []string{"biking_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fd\u200d\u2642\ufe0f", "man biking: Medium Skin Tone", []string{"biking_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\u200d\u2640\ufe0f", "woman biking", []string{"biking_woman"}, "6.0", true},
+ {"\U0001f6b4\U0001f3ff\u200d\u2640\ufe0f", "woman biking: Dark Skin Tone", []string{"biking_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fb\u200d\u2640\ufe0f", "woman biking: Light Skin Tone", []string{"biking_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fe\u200d\u2640\ufe0f", "woman biking: Medium-Dark Skin Tone", []string{"biking_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fc\u200d\u2640\ufe0f", "woman biking: Medium-Light Skin Tone", []string{"biking_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b4\U0001f3fd\u200d\u2640\ufe0f", "woman biking: Medium Skin Tone", []string{"biking_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f459", "bikini", []string{"bikini"}, "6.0", false},
+ {"\U0001f9e2", "billed cap", []string{"billed_cap"}, "11.0", false},
+ {"\u2623\ufe0f", "biohazard", []string{"biohazard"}, "", false},
+ {"\U0001f426", "bird", []string{"bird"}, "6.0", false},
+ {"\U0001f382", "birthday cake", []string{"birthday"}, "6.0", false},
+ {"\U0001f9ac", "bison", []string{"bison"}, "13.0", false},
+ {"\U0001fae6", "biting lip", []string{"biting_lip"}, "14.0", false},
+ {"\U0001f426\u200d\u2b1b", "black bird", []string{"black_bird"}, "15.0", false},
+ {"\U0001f408\u200d\u2b1b", "black cat", []string{"black_cat"}, "13.0", false},
+ {"\u26ab", "black circle", []string{"black_circle"}, "4.1", false},
+ {"\U0001f3f4", "black flag", []string{"black_flag"}, "7.0", false},
+ {"\U0001f5a4", "black heart", []string{"black_heart"}, "9.0", false},
+ {"\U0001f0cf", "joker", []string{"black_joker"}, "6.0", false},
+ {"\u2b1b", "black large square", []string{"black_large_square"}, "5.1", false},
+ {"\u25fe", "black medium-small square", []string{"black_medium_small_square"}, "3.2", false},
+ {"\u25fc\ufe0f", "black medium square", []string{"black_medium_square"}, "3.2", false},
+ {"\u2712\ufe0f", "black nib", []string{"black_nib"}, "", false},
+ {"\u25aa\ufe0f", "black small square", []string{"black_small_square"}, "", false},
+ {"\U0001f532", "black square button", []string{"black_square_button"}, "6.0", false},
+ {"\U0001f471\u200d\u2642\ufe0f", "man: blond hair", []string{"blond_haired_man"}, "11.0", true},
+ {"\U0001f471\U0001f3ff\u200d\u2642\ufe0f", "man: blond hair: Dark Skin Tone", []string{"blond_haired_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fb\u200d\u2642\ufe0f", "man: blond hair: Light Skin Tone", []string{"blond_haired_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fe\u200d\u2642\ufe0f", "man: blond hair: Medium-Dark Skin Tone", []string{"blond_haired_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fc\u200d\u2642\ufe0f", "man: blond hair: Medium-Light Skin Tone", []string{"blond_haired_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fd\u200d\u2642\ufe0f", "man: blond hair: Medium Skin Tone", []string{"blond_haired_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f471", "person: blond hair", []string{"blond_haired_person"}, "6.0", true},
+ {"\U0001f471\U0001f3ff", "person: blond hair: Dark Skin Tone", []string{"blond_haired_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fb", "person: blond hair: Light Skin Tone", []string{"blond_haired_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fe", "person: blond hair: Medium-Dark Skin Tone", []string{"blond_haired_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fc", "person: blond hair: Medium-Light Skin Tone", []string{"blond_haired_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fd", "person: blond hair: Medium Skin Tone", []string{"blond_haired_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\u200d\u2640\ufe0f", "woman: blond hair", []string{"blond_haired_woman", "blonde_woman"}, "6.0", true},
+ {"\U0001f471\U0001f3ff\u200d\u2640\ufe0f", "woman: blond hair: Dark Skin Tone", []string{"blond_haired_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fb\u200d\u2640\ufe0f", "woman: blond hair: Light Skin Tone", []string{"blond_haired_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fe\u200d\u2640\ufe0f", "woman: blond hair: Medium-Dark Skin Tone", []string{"blond_haired_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fc\u200d\u2640\ufe0f", "woman: blond hair: Medium-Light Skin Tone", []string{"blond_haired_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f471\U0001f3fd\u200d\u2640\ufe0f", "woman: blond hair: Medium Skin Tone", []string{"blond_haired_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f33c", "blossom", []string{"blossom"}, "6.0", false},
+ {"\U0001f421", "blowfish", []string{"blowfish"}, "6.0", false},
+ {"\U0001f4d8", "blue book", []string{"blue_book"}, "6.0", false},
+ {"\U0001f699", "sport utility vehicle", []string{"blue_car"}, "6.0", false},
+ {"\U0001f499", "blue heart", []string{"blue_heart"}, "6.0", false},
+ {"\U0001f7e6", "blue square", []string{"blue_square"}, "12.0", false},
+ {"\U0001fad0", "blueberries", []string{"blueberries"}, "13.0", false},
+ {"\U0001f60a", "smiling face with smiling eyes", []string{"blush"}, "6.0", false},
+ {"\U0001f417", "boar", []string{"boar"}, "6.0", false},
+ {"\u26f5", "sailboat", []string{"boat", "sailboat"}, "5.2", false},
+ {"\U0001f1e7\U0001f1f4", "flag: Bolivia", []string{"bolivia"}, "6.0", false},
+ {"\U0001f4a3", "bomb", []string{"bomb"}, "6.0", false},
+ {"\U0001f9b4", "bone", []string{"bone"}, "11.0", false},
+ {"\U0001f4d6", "open book", []string{"book", "open_book"}, "6.0", false},
+ {"\U0001f516", "bookmark", []string{"bookmark"}, "6.0", false},
+ {"\U0001f4d1", "bookmark tabs", []string{"bookmark_tabs"}, "6.0", false},
+ {"\U0001f4da", "books", []string{"books"}, "6.0", false},
+ {"\U0001f4a5", "collision", []string{"boom", "collision"}, "6.0", false},
+ {"\U0001fa83", "boomerang", []string{"boomerang"}, "13.0", false},
+ {"\U0001f462", "woman’s boot", []string{"boot"}, "6.0", false},
+ {"\U0001f1e7\U0001f1e6", "flag: Bosnia & Herzegovina", []string{"bosnia_herzegovina"}, "6.0", false},
+ {"\U0001f1e7\U0001f1fc", "flag: Botswana", []string{"botswana"}, "6.0", false},
+ {"\u26f9\ufe0f\u200d\u2642\ufe0f", "man bouncing ball", []string{"bouncing_ball_man", "basketball_man"}, "11.0", true},
+ {"\u26f9\U0001f3ff\ufe0f\u200d\u2642\ufe0f", "man bouncing ball: Dark Skin Tone", []string{"bouncing_ball_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fb\ufe0f\u200d\u2642\ufe0f", "man bouncing ball: Light Skin Tone", []string{"bouncing_ball_man_Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fe\ufe0f\u200d\u2642\ufe0f", "man bouncing ball: Medium-Dark Skin Tone", []string{"bouncing_ball_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fc\ufe0f\u200d\u2642\ufe0f", "man bouncing ball: Medium-Light Skin Tone", []string{"bouncing_ball_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fd\ufe0f\u200d\u2642\ufe0f", "man bouncing ball: Medium Skin Tone", []string{"bouncing_ball_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\u26f9\ufe0f", "person bouncing ball", []string{"bouncing_ball_person"}, "5.2", true},
+ {"\u26f9\U0001f3ff\ufe0f", "person bouncing ball: Dark Skin Tone", []string{"bouncing_ball_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fb\ufe0f", "person bouncing ball: Light Skin Tone", []string{"bouncing_ball_person_Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fe\ufe0f", "person bouncing ball: Medium-Dark Skin Tone", []string{"bouncing_ball_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fc\ufe0f", "person bouncing ball: Medium-Light Skin Tone", []string{"bouncing_ball_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fd\ufe0f", "person bouncing ball: Medium Skin Tone", []string{"bouncing_ball_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\u26f9\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball", []string{"bouncing_ball_woman", "basketball_woman"}, "7.0", true},
+ {"\u26f9\U0001f3ff\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball: Dark Skin Tone", []string{"bouncing_ball_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fb\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball: Light Skin Tone", []string{"bouncing_ball_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fe\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball: Medium-Dark Skin Tone", []string{"bouncing_ball_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fc\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball: Medium-Light Skin Tone", []string{"bouncing_ball_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u26f9\U0001f3fd\ufe0f\u200d\u2640\ufe0f", "woman bouncing ball: Medium Skin Tone", []string{"bouncing_ball_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f490", "bouquet", []string{"bouquet"}, "6.0", false},
+ {"\U0001f1e7\U0001f1fb", "flag: Bouvet Island", []string{"bouvet_island"}, "11.0", false},
+ {"\U0001f647", "person bowing", []string{"bow"}, "6.0", true},
+ {"\U0001f647\U0001f3ff", "person bowing: Dark Skin Tone", []string{"bow_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fb", "person bowing: Light Skin Tone", []string{"bow_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fe", "person bowing: Medium-Dark Skin Tone", []string{"bow_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fc", "person bowing: Medium-Light Skin Tone", []string{"bow_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fd", "person bowing: Medium Skin Tone", []string{"bow_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3f9", "bow and arrow", []string{"bow_and_arrow"}, "8.0", false},
+ {"\U0001f647\u200d\u2642\ufe0f", "man bowing", []string{"bowing_man"}, "11.0", true},
+ {"\U0001f647\U0001f3ff\u200d\u2642\ufe0f", "man bowing: Dark Skin Tone", []string{"bowing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fb\u200d\u2642\ufe0f", "man bowing: Light Skin Tone", []string{"bowing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fe\u200d\u2642\ufe0f", "man bowing: Medium-Dark Skin Tone", []string{"bowing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fc\u200d\u2642\ufe0f", "man bowing: Medium-Light Skin Tone", []string{"bowing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fd\u200d\u2642\ufe0f", "man bowing: Medium Skin Tone", []string{"bowing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\u200d\u2640\ufe0f", "woman bowing", []string{"bowing_woman"}, "6.0", true},
+ {"\U0001f647\U0001f3ff\u200d\u2640\ufe0f", "woman bowing: Dark Skin Tone", []string{"bowing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fb\u200d\u2640\ufe0f", "woman bowing: Light Skin Tone", []string{"bowing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fe\u200d\u2640\ufe0f", "woman bowing: Medium-Dark Skin Tone", []string{"bowing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fc\u200d\u2640\ufe0f", "woman bowing: Medium-Light Skin Tone", []string{"bowing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f647\U0001f3fd\u200d\u2640\ufe0f", "woman bowing: Medium Skin Tone", []string{"bowing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f963", "bowl with spoon", []string{"bowl_with_spoon"}, "11.0", false},
+ {"\U0001f3b3", "bowling", []string{"bowling"}, "6.0", false},
+ {"\U0001f94a", "boxing glove", []string{"boxing_glove"}, "9.0", false},
+ {"\U0001f466", "boy", []string{"boy"}, "6.0", true},
+ {"\U0001f466\U0001f3ff", "boy: Dark Skin Tone", []string{"boy_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f466\U0001f3fb", "boy: Light Skin Tone", []string{"boy_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f466\U0001f3fe", "boy: Medium-Dark Skin Tone", []string{"boy_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f466\U0001f3fc", "boy: Medium-Light Skin Tone", []string{"boy_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f466\U0001f3fd", "boy: Medium Skin Tone", []string{"boy_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9e0", "brain", []string{"brain"}, "11.0", false},
+ {"\U0001f1e7\U0001f1f7", "flag: Brazil", []string{"brazil"}, "6.0", false},
+ {"\U0001f35e", "bread", []string{"bread"}, "6.0", false},
+ {"\U0001f931", "breast-feeding", []string{"breast_feeding"}, "11.0", true},
+ {"\U0001f931\U0001f3ff", "breast-feeding: Dark Skin Tone", []string{"breast_feeding_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f931\U0001f3fb", "breast-feeding: Light Skin Tone", []string{"breast_feeding_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f931\U0001f3fe", "breast-feeding: Medium-Dark Skin Tone", []string{"breast_feeding_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f931\U0001f3fc", "breast-feeding: Medium-Light Skin Tone", []string{"breast_feeding_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f931\U0001f3fd", "breast-feeding: Medium Skin Tone", []string{"breast_feeding_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9f1", "brick", []string{"bricks"}, "11.0", false},
+ {"\U0001f309", "bridge at night", []string{"bridge_at_night"}, "6.0", false},
+ {"\U0001f4bc", "briefcase", []string{"briefcase"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f4", "flag: British Indian Ocean Territory", []string{"british_indian_ocean_territory"}, "6.0", false},
+ {"\U0001f1fb\U0001f1ec", "flag: British Virgin Islands", []string{"british_virgin_islands"}, "6.0", false},
+ {"\U0001f966", "broccoli", []string{"broccoli"}, "11.0", false},
+ {"\U0001f494", "broken heart", []string{"broken_heart"}, "6.0", false},
+ {"\U0001f9f9", "broom", []string{"broom"}, "11.0", false},
+ {"\U0001f7e4", "brown circle", []string{"brown_circle"}, "12.0", false},
+ {"\U0001f90e", "brown heart", []string{"brown_heart"}, "12.0", false},
+ {"\U0001f7eb", "brown square", []string{"brown_square"}, "12.0", false},
+ {"\U0001f1e7\U0001f1f3", "flag: Brunei", []string{"brunei"}, "6.0", false},
+ {"\U0001f9cb", "bubble tea", []string{"bubble_tea"}, "13.0", false},
+ {"\U0001fae7", "bubbles", []string{"bubbles"}, "14.0", false},
+ {"\U0001faa3", "bucket", []string{"bucket"}, "13.0", false},
+ {"\U0001f41b", "bug", []string{"bug"}, "6.0", false},
+ {"\U0001f3d7\ufe0f", "building construction", []string{"building_construction"}, "7.0", false},
+ {"\U0001f4a1", "light bulb", []string{"bulb"}, "6.0", false},
+ {"\U0001f1e7\U0001f1ec", "flag: Bulgaria", []string{"bulgaria"}, "6.0", false},
+ {"\U0001f685", "bullet train", []string{"bullettrain_front"}, "6.0", false},
+ {"\U0001f684", "high-speed train", []string{"bullettrain_side"}, "6.0", false},
+ {"\U0001f1e7\U0001f1eb", "flag: Burkina Faso", []string{"burkina_faso"}, "6.0", false},
+ {"\U0001f32f", "burrito", []string{"burrito"}, "8.0", false},
+ {"\U0001f1e7\U0001f1ee", "flag: Burundi", []string{"burundi"}, "6.0", false},
+ {"\U0001f68c", "bus", []string{"bus"}, "6.0", false},
+ {"\U0001f574\ufe0f", "person in suit levitating", []string{"business_suit_levitating"}, "7.0", true},
+ {"\U0001f574\U0001f3ff\ufe0f", "person in suit levitating: Dark Skin Tone", []string{"business_suit_levitating_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f574\U0001f3fb\ufe0f", "person in suit levitating: Light Skin Tone", []string{"business_suit_levitating_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f574\U0001f3fe\ufe0f", "person in suit levitating: Medium-Dark Skin Tone", []string{"business_suit_levitating_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f574\U0001f3fc\ufe0f", "person in suit levitating: Medium-Light Skin Tone", []string{"business_suit_levitating_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f574\U0001f3fd\ufe0f", "person in suit levitating: Medium Skin Tone", []string{"business_suit_levitating_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f68f", "bus stop", []string{"busstop"}, "6.0", false},
+ {"\U0001f464", "bust in silhouette", []string{"bust_in_silhouette"}, "6.0", false},
+ {"\U0001f465", "busts in silhouette", []string{"busts_in_silhouette"}, "6.0", false},
+ {"\U0001f9c8", "butter", []string{"butter"}, "12.0", false},
+ {"\U0001f98b", "butterfly", []string{"butterfly"}, "9.0", false},
+ {"\U0001f335", "cactus", []string{"cactus"}, "6.0", false},
+ {"\U0001f370", "shortcake", []string{"cake"}, "6.0", false},
+ {"\U0001f4c6", "tear-off calendar", []string{"calendar"}, "6.0", false},
+ {"\U0001f919", "call me hand", []string{"call_me_hand"}, "9.0", true},
+ {"\U0001f919\U0001f3ff", "call me hand: Dark Skin Tone", []string{"call_me_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f919\U0001f3fb", "call me hand: Light Skin Tone", []string{"call_me_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f919\U0001f3fe", "call me hand: Medium-Dark Skin Tone", []string{"call_me_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f919\U0001f3fc", "call me hand: Medium-Light Skin Tone", []string{"call_me_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f919\U0001f3fd", "call me hand: Medium Skin Tone", []string{"call_me_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4f2", "mobile phone with arrow", []string{"calling"}, "6.0", false},
+ {"\U0001f1f0\U0001f1ed", "flag: Cambodia", []string{"cambodia"}, "6.0", false},
+ {"\U0001f42b", "two-hump camel", []string{"camel"}, "6.0", false},
+ {"\U0001f4f7", "camera", []string{"camera"}, "6.0", false},
+ {"\U0001f4f8", "camera with flash", []string{"camera_flash"}, "7.0", false},
+ {"\U0001f1e8\U0001f1f2", "flag: Cameroon", []string{"cameroon"}, "6.0", false},
+ {"\U0001f3d5\ufe0f", "camping", []string{"camping"}, "7.0", false},
+ {"\U0001f1e8\U0001f1e6", "flag: Canada", []string{"canada"}, "6.0", false},
+ {"\U0001f1ee\U0001f1e8", "flag: Canary Islands", []string{"canary_islands"}, "6.0", false},
+ {"\u264b", "Cancer", []string{"cancer"}, "", false},
+ {"\U0001f56f\ufe0f", "candle", []string{"candle"}, "7.0", false},
+ {"\U0001f36c", "candy", []string{"candy"}, "6.0", false},
+ {"\U0001f96b", "canned food", []string{"canned_food"}, "11.0", false},
+ {"\U0001f6f6", "canoe", []string{"canoe"}, "9.0", false},
+ {"\U0001f1e8\U0001f1fb", "flag: Cape Verde", []string{"cape_verde"}, "6.0", false},
+ {"\U0001f520", "input latin uppercase", []string{"capital_abcd"}, "6.0", false},
+ {"\u2651", "Capricorn", []string{"capricorn"}, "", false},
+ {"\U0001f697", "automobile", []string{"car", "red_car"}, "6.0", false},
+ {"\U0001f5c3\ufe0f", "card file box", []string{"card_file_box"}, "7.0", false},
+ {"\U0001f4c7", "card index", []string{"card_index"}, "6.0", false},
+ {"\U0001f5c2\ufe0f", "card index dividers", []string{"card_index_dividers"}, "7.0", false},
+ {"\U0001f1e7\U0001f1f6", "flag: Caribbean Netherlands", []string{"caribbean_netherlands"}, "6.0", false},
+ {"\U0001f3a0", "carousel horse", []string{"carousel_horse"}, "6.0", false},
+ {"\U0001fa9a", "carpentry saw", []string{"carpentry_saw"}, "13.0", false},
+ {"\U0001f955", "carrot", []string{"carrot"}, "9.0", false},
+ {"\U0001f938", "person cartwheeling", []string{"cartwheeling"}, "11.0", true},
+ {"\U0001f938\U0001f3ff", "person cartwheeling: Dark Skin Tone", []string{"cartwheeling_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fb", "person cartwheeling: Light Skin Tone", []string{"cartwheeling_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fe", "person cartwheeling: Medium-Dark Skin Tone", []string{"cartwheeling_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fc", "person cartwheeling: Medium-Light Skin Tone", []string{"cartwheeling_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fd", "person cartwheeling: Medium Skin Tone", []string{"cartwheeling_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f431", "cat face", []string{"cat"}, "6.0", false},
+ {"\U0001f408", "cat", []string{"cat2"}, "6.0", false},
+ {"\U0001f1f0\U0001f1fe", "flag: Cayman Islands", []string{"cayman_islands"}, "6.0", false},
+ {"\U0001f4bf", "optical disk", []string{"cd"}, "6.0", false},
+ {"\U0001f1e8\U0001f1eb", "flag: Central African Republic", []string{"central_african_republic"}, "6.0", false},
+ {"\U0001f1ea\U0001f1e6", "flag: Ceuta & Melilla", []string{"ceuta_melilla"}, "11.0", false},
+ {"\U0001f1f9\U0001f1e9", "flag: Chad", []string{"chad"}, "6.0", false},
+ {"\u26d3\ufe0f", "chains", []string{"chains"}, "5.2", false},
+ {"\U0001fa91", "chair", []string{"chair"}, "12.0", false},
+ {"\U0001f37e", "bottle with popping cork", []string{"champagne"}, "8.0", false},
+ {"\U0001f4b9", "chart increasing with yen", []string{"chart"}, "6.0", false},
+ {"\U0001f4c9", "chart decreasing", []string{"chart_with_downwards_trend"}, "6.0", false},
+ {"\U0001f4c8", "chart increasing", []string{"chart_with_upwards_trend"}, "6.0", false},
+ {"\U0001f3c1", "chequered flag", []string{"checkered_flag"}, "6.0", false},
+ {"\U0001f9c0", "cheese wedge", []string{"cheese"}, "8.0", false},
+ {"\U0001f352", "cherries", []string{"cherries"}, "6.0", false},
+ {"\U0001f338", "cherry blossom", []string{"cherry_blossom"}, "6.0", false},
+ {"\u265f\ufe0f", "chess pawn", []string{"chess_pawn"}, "11.0", false},
+ {"\U0001f330", "chestnut", []string{"chestnut"}, "6.0", false},
+ {"\U0001f414", "chicken", []string{"chicken"}, "6.0", false},
+ {"\U0001f9d2", "child", []string{"child"}, "11.0", true},
+ {"\U0001f9d2\U0001f3ff", "child: Dark Skin Tone", []string{"child_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d2\U0001f3fb", "child: Light Skin Tone", []string{"child_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d2\U0001f3fe", "child: Medium-Dark Skin Tone", []string{"child_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d2\U0001f3fc", "child: Medium-Light Skin Tone", []string{"child_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d2\U0001f3fd", "child: Medium Skin Tone", []string{"child_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b8", "children crossing", []string{"children_crossing"}, "6.0", false},
+ {"\U0001f1e8\U0001f1f1", "flag: Chile", []string{"chile"}, "6.0", false},
+ {"\U0001f43f\ufe0f", "chipmunk", []string{"chipmunk"}, "7.0", false},
+ {"\U0001f36b", "chocolate bar", []string{"chocolate_bar"}, "6.0", false},
+ {"\U0001f962", "chopsticks", []string{"chopsticks"}, "11.0", false},
+ {"\U0001f1e8\U0001f1fd", "flag: Christmas Island", []string{"christmas_island"}, "6.0", false},
+ {"\U0001f384", "Christmas tree", []string{"christmas_tree"}, "6.0", false},
+ {"\u26ea", "church", []string{"church"}, "5.2", false},
+ {"\U0001f3a6", "cinema", []string{"cinema"}, "6.0", false},
+ {"\U0001f3aa", "circus tent", []string{"circus_tent"}, "6.0", false},
+ {"\U0001f307", "sunset", []string{"city_sunrise"}, "6.0", false},
+ {"\U0001f306", "cityscape at dusk", []string{"city_sunset"}, "6.0", false},
+ {"\U0001f3d9\ufe0f", "cityscape", []string{"cityscape"}, "7.0", false},
+ {"\U0001f191", "CL button", []string{"cl"}, "6.0", false},
+ {"\U0001f5dc\ufe0f", "clamp", []string{"clamp"}, "7.0", false},
+ {"\U0001f44f", "clapping hands", []string{"clap"}, "6.0", true},
+ {"\U0001f44f\U0001f3ff", "clapping hands: Dark Skin Tone", []string{"clap_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44f\U0001f3fb", "clapping hands: Light Skin Tone", []string{"clap_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44f\U0001f3fe", "clapping hands: Medium-Dark Skin Tone", []string{"clap_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44f\U0001f3fc", "clapping hands: Medium-Light Skin Tone", []string{"clap_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44f\U0001f3fd", "clapping hands: Medium Skin Tone", []string{"clap_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ac", "clapper board", []string{"clapper"}, "6.0", false},
+ {"\U0001f3db\ufe0f", "classical building", []string{"classical_building"}, "7.0", false},
+ {"\U0001f9d7", "person climbing", []string{"climbing"}, "11.0", true},
+ {"\U0001f9d7\U0001f3ff", "person climbing: Dark Skin Tone", []string{"climbing_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fb", "person climbing: Light Skin Tone", []string{"climbing_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fe", "person climbing: Medium-Dark Skin Tone", []string{"climbing_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fc", "person climbing: Medium-Light Skin Tone", []string{"climbing_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fd", "person climbing: Medium Skin Tone", []string{"climbing_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\u200d\u2642\ufe0f", "man climbing", []string{"climbing_man"}, "11.0", true},
+ {"\U0001f9d7\U0001f3ff\u200d\u2642\ufe0f", "man climbing: Dark Skin Tone", []string{"climbing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fb\u200d\u2642\ufe0f", "man climbing: Light Skin Tone", []string{"climbing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fe\u200d\u2642\ufe0f", "man climbing: Medium-Dark Skin Tone", []string{"climbing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fc\u200d\u2642\ufe0f", "man climbing: Medium-Light Skin Tone", []string{"climbing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fd\u200d\u2642\ufe0f", "man climbing: Medium Skin Tone", []string{"climbing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\u200d\u2640\ufe0f", "woman climbing", []string{"climbing_woman"}, "11.0", true},
+ {"\U0001f9d7\U0001f3ff\u200d\u2640\ufe0f", "woman climbing: Dark Skin Tone", []string{"climbing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fb\u200d\u2640\ufe0f", "woman climbing: Light Skin Tone", []string{"climbing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fe\u200d\u2640\ufe0f", "woman climbing: Medium-Dark Skin Tone", []string{"climbing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fc\u200d\u2640\ufe0f", "woman climbing: Medium-Light Skin Tone", []string{"climbing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d7\U0001f3fd\u200d\u2640\ufe0f", "woman climbing: Medium Skin Tone", []string{"climbing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f942", "clinking glasses", []string{"clinking_glasses"}, "9.0", false},
+ {"\U0001f4cb", "clipboard", []string{"clipboard"}, "6.0", false},
+ {"\U0001f1e8\U0001f1f5", "flag: Clipperton Island", []string{"clipperton_island"}, "11.0", false},
+ {"\U0001f550", "one o’clock", []string{"clock1"}, "6.0", false},
+ {"\U0001f559", "ten o’clock", []string{"clock10"}, "6.0", false},
+ {"\U0001f565", "ten-thirty", []string{"clock1030"}, "6.0", false},
+ {"\U0001f55a", "eleven o’clock", []string{"clock11"}, "6.0", false},
+ {"\U0001f566", "eleven-thirty", []string{"clock1130"}, "6.0", false},
+ {"\U0001f55b", "twelve o’clock", []string{"clock12"}, "6.0", false},
+ {"\U0001f567", "twelve-thirty", []string{"clock1230"}, "6.0", false},
+ {"\U0001f55c", "one-thirty", []string{"clock130"}, "6.0", false},
+ {"\U0001f551", "two o’clock", []string{"clock2"}, "6.0", false},
+ {"\U0001f55d", "two-thirty", []string{"clock230"}, "6.0", false},
+ {"\U0001f552", "three o’clock", []string{"clock3"}, "6.0", false},
+ {"\U0001f55e", "three-thirty", []string{"clock330"}, "6.0", false},
+ {"\U0001f553", "four o’clock", []string{"clock4"}, "6.0", false},
+ {"\U0001f55f", "four-thirty", []string{"clock430"}, "6.0", false},
+ {"\U0001f554", "five o’clock", []string{"clock5"}, "6.0", false},
+ {"\U0001f560", "five-thirty", []string{"clock530"}, "6.0", false},
+ {"\U0001f555", "six o’clock", []string{"clock6"}, "6.0", false},
+ {"\U0001f561", "six-thirty", []string{"clock630"}, "6.0", false},
+ {"\U0001f556", "seven o’clock", []string{"clock7"}, "6.0", false},
+ {"\U0001f562", "seven-thirty", []string{"clock730"}, "6.0", false},
+ {"\U0001f557", "eight o’clock", []string{"clock8"}, "6.0", false},
+ {"\U0001f563", "eight-thirty", []string{"clock830"}, "6.0", false},
+ {"\U0001f558", "nine o’clock", []string{"clock9"}, "6.0", false},
+ {"\U0001f564", "nine-thirty", []string{"clock930"}, "6.0", false},
+ {"\U0001f4d5", "closed book", []string{"closed_book"}, "6.0", false},
+ {"\U0001f510", "locked with key", []string{"closed_lock_with_key"}, "6.0", false},
+ {"\U0001f302", "closed umbrella", []string{"closed_umbrella"}, "6.0", false},
+ {"\u2601\ufe0f", "cloud", []string{"cloud"}, "", false},
+ {"\U0001f329\ufe0f", "cloud with lightning", []string{"cloud_with_lightning"}, "7.0", false},
+ {"\u26c8\ufe0f", "cloud with lightning and rain", []string{"cloud_with_lightning_and_rain"}, "5.2", false},
+ {"\U0001f327\ufe0f", "cloud with rain", []string{"cloud_with_rain"}, "7.0", false},
+ {"\U0001f328\ufe0f", "cloud with snow", []string{"cloud_with_snow"}, "7.0", false},
+ {"\U0001f921", "clown face", []string{"clown_face"}, "9.0", false},
+ {"\u2663\ufe0f", "club suit", []string{"clubs"}, "", false},
+ {"\U0001f1e8\U0001f1f3", "flag: China", []string{"cn"}, "6.0", false},
+ {"\U0001f9e5", "coat", []string{"coat"}, "11.0", false},
+ {"\U0001fab3", "cockroach", []string{"cockroach"}, "13.0", false},
+ {"\U0001f378", "cocktail glass", []string{"cocktail"}, "6.0", false},
+ {"\U0001f965", "coconut", []string{"coconut"}, "11.0", false},
+ {"\U0001f1e8\U0001f1e8", "flag: Cocos (Keeling) Islands", []string{"cocos_islands"}, "6.0", false},
+ {"\u2615", "hot beverage", []string{"coffee"}, "4.0", false},
+ {"\u26b0\ufe0f", "coffin", []string{"coffin"}, "4.1", false},
+ {"\U0001fa99", "coin", []string{"coin"}, "13.0", false},
+ {"\U0001f976", "cold face", []string{"cold_face"}, "11.0", false},
+ {"\U0001f630", "anxious face with sweat", []string{"cold_sweat"}, "6.0", false},
+ {"\U0001f1e8\U0001f1f4", "flag: Colombia", []string{"colombia"}, "6.0", false},
+ {"\u2604\ufe0f", "comet", []string{"comet"}, "", false},
+ {"\U0001f1f0\U0001f1f2", "flag: Comoros", []string{"comoros"}, "6.0", false},
+ {"\U0001f9ed", "compass", []string{"compass"}, "11.0", false},
+ {"\U0001f4bb", "laptop", []string{"computer"}, "6.0", false},
+ {"\U0001f5b1\ufe0f", "computer mouse", []string{"computer_mouse"}, "7.0", false},
+ {"\U0001f38a", "confetti ball", []string{"confetti_ball"}, "6.0", false},
+ {"\U0001f616", "confounded face", []string{"confounded"}, "6.0", false},
+ {"\U0001f615", "confused face", []string{"confused"}, "6.1", false},
+ {"\U0001f1e8\U0001f1ec", "flag: Congo - Brazzaville", []string{"congo_brazzaville"}, "6.0", false},
+ {"\U0001f1e8\U0001f1e9", "flag: Congo - Kinshasa", []string{"congo_kinshasa"}, "6.0", false},
+ {"\u3297\ufe0f", "Japanese “congratulations†button", []string{"congratulations"}, "", false},
+ {"\U0001f6a7", "construction", []string{"construction"}, "6.0", false},
+ {"\U0001f477", "construction worker", []string{"construction_worker"}, "6.0", true},
+ {"\U0001f477\U0001f3ff", "construction worker: Dark Skin Tone", []string{"construction_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fb", "construction worker: Light Skin Tone", []string{"construction_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fe", "construction worker: Medium-Dark Skin Tone", []string{"construction_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fc", "construction worker: Medium-Light Skin Tone", []string{"construction_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fd", "construction worker: Medium Skin Tone", []string{"construction_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\u200d\u2642\ufe0f", "man construction worker", []string{"construction_worker_man"}, "11.0", true},
+ {"\U0001f477\U0001f3ff\u200d\u2642\ufe0f", "man construction worker: Dark Skin Tone", []string{"construction_worker_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fb\u200d\u2642\ufe0f", "man construction worker: Light Skin Tone", []string{"construction_worker_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fe\u200d\u2642\ufe0f", "man construction worker: Medium-Dark Skin Tone", []string{"construction_worker_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fc\u200d\u2642\ufe0f", "man construction worker: Medium-Light Skin Tone", []string{"construction_worker_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fd\u200d\u2642\ufe0f", "man construction worker: Medium Skin Tone", []string{"construction_worker_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\u200d\u2640\ufe0f", "woman construction worker", []string{"construction_worker_woman"}, "6.0", true},
+ {"\U0001f477\U0001f3ff\u200d\u2640\ufe0f", "woman construction worker: Dark Skin Tone", []string{"construction_worker_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fb\u200d\u2640\ufe0f", "woman construction worker: Light Skin Tone", []string{"construction_worker_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fe\u200d\u2640\ufe0f", "woman construction worker: Medium-Dark Skin Tone", []string{"construction_worker_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fc\u200d\u2640\ufe0f", "woman construction worker: Medium-Light Skin Tone", []string{"construction_worker_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f477\U0001f3fd\u200d\u2640\ufe0f", "woman construction worker: Medium Skin Tone", []string{"construction_worker_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f39b\ufe0f", "control knobs", []string{"control_knobs"}, "7.0", false},
+ {"\U0001f3ea", "convenience store", []string{"convenience_store"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f373", "cook", []string{"cook"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f373", "cook: Dark Skin Tone", []string{"cook_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f373", "cook: Light Skin Tone", []string{"cook_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f373", "cook: Medium-Dark Skin Tone", []string{"cook_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f373", "cook: Medium-Light Skin Tone", []string{"cook_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f373", "cook: Medium Skin Tone", []string{"cook_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1e8\U0001f1f0", "flag: Cook Islands", []string{"cook_islands"}, "6.0", false},
+ {"\U0001f36a", "cookie", []string{"cookie"}, "6.0", false},
+ {"\U0001f192", "COOL button", []string{"cool"}, "6.0", false},
+ {"\u00a9\ufe0f", "copyright", []string{"copyright"}, "", false},
+ {"\U0001fab8", "coral", []string{"coral"}, "14.0", false},
+ {"\U0001f33d", "ear of corn", []string{"corn"}, "6.0", false},
+ {"\U0001f1e8\U0001f1f7", "flag: Costa Rica", []string{"costa_rica"}, "6.0", false},
+ {"\U0001f1e8\U0001f1ee", "flag: Côte d’Ivoire", []string{"cote_divoire"}, "6.0", false},
+ {"\U0001f6cb\ufe0f", "couch and lamp", []string{"couch_and_lamp"}, "7.0", false},
+ {"\U0001f46b", "woman and man holding hands", []string{"couple"}, "6.0", true},
+ {"\U0001f46b\U0001f3ff", "woman and man holding hands: Dark Skin Tone", []string{"couple_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46b\U0001f3fb", "woman and man holding hands: Light Skin Tone", []string{"couple_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46b\U0001f3fe", "woman and man holding hands: Medium-Dark Skin Tone", []string{"couple_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46b\U0001f3fc", "woman and man holding hands: Medium-Light Skin Tone", []string{"couple_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46b\U0001f3fd", "woman and man holding hands: Medium Skin Tone", []string{"couple_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f491", "couple with heart", []string{"couple_with_heart"}, "6.0", true},
+ {"\U0001f491\U0001f3ff", "couple with heart: Dark Skin Tone", []string{"couple_with_heart_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f491\U0001f3fb", "couple with heart: Light Skin Tone", []string{"couple_with_heart_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f491\U0001f3fe", "couple with heart: Medium-Dark Skin Tone", []string{"couple_with_heart_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f491\U0001f3fc", "couple with heart: Medium-Light Skin Tone", []string{"couple_with_heart_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f491\U0001f3fd", "couple with heart: Medium Skin Tone", []string{"couple_with_heart_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man", []string{"couple_with_heart_man_man"}, "6.0", true},
+ {"\U0001f468\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man: Dark Skin Tone", []string{"couple_with_heart_man_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man: Light Skin Tone", []string{"couple_with_heart_man_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man: Medium-Dark Skin Tone", []string{"couple_with_heart_man_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man: Medium-Light Skin Tone", []string{"couple_with_heart_man_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: man, man: Medium Skin Tone", []string{"couple_with_heart_man_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man", []string{"couple_with_heart_woman_man"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man: Dark Skin Tone", []string{"couple_with_heart_woman_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man: Light Skin Tone", []string{"couple_with_heart_woman_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man: Medium-Dark Skin Tone", []string{"couple_with_heart_woman_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man: Medium-Light Skin Tone", []string{"couple_with_heart_woman_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f468", "couple with heart: woman, man: Medium Skin Tone", []string{"couple_with_heart_woman_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman", []string{"couple_with_heart_woman_woman"}, "6.0", true},
+ {"\U0001f469\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman: Dark Skin Tone", []string{"couple_with_heart_woman_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman: Light Skin Tone", []string{"couple_with_heart_woman_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman: Medium-Dark Skin Tone", []string{"couple_with_heart_woman_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman: Medium-Light Skin Tone", []string{"couple_with_heart_woman_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f469", "couple with heart: woman, woman: Medium Skin Tone", []string{"couple_with_heart_woman_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f48f", "kiss", []string{"couplekiss"}, "6.0", true},
+ {"\U0001f48f\U0001f3ff", "kiss: Dark Skin Tone", []string{"couplekiss_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f48f\U0001f3fb", "kiss: Light Skin Tone", []string{"couplekiss_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f48f\U0001f3fe", "kiss: Medium-Dark Skin Tone", []string{"couplekiss_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f48f\U0001f3fc", "kiss: Medium-Light Skin Tone", []string{"couplekiss_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f48f\U0001f3fd", "kiss: Medium Skin Tone", []string{"couplekiss_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man", []string{"couplekiss_man_man"}, "6.0", true},
+ {"\U0001f468\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man: Dark Skin Tone", []string{"couplekiss_man_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man: Light Skin Tone", []string{"couplekiss_man_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man: Medium-Dark Skin Tone", []string{"couplekiss_man_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man: Medium-Light Skin Tone", []string{"couplekiss_man_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: man, man: Medium Skin Tone", []string{"couplekiss_man_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man", []string{"couplekiss_man_woman"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man: Dark Skin Tone", []string{"couplekiss_man_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man: Light Skin Tone", []string{"couplekiss_man_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man: Medium-Dark Skin Tone", []string{"couplekiss_man_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man: Medium-Light Skin Tone", []string{"couplekiss_man_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", "kiss: woman, man: Medium Skin Tone", []string{"couplekiss_man_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman", []string{"couplekiss_woman_woman"}, "6.0", true},
+ {"\U0001f469\U0001f3ff\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman: Dark Skin Tone", []string{"couplekiss_woman_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman: Light Skin Tone", []string{"couplekiss_woman_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman: Medium-Dark Skin Tone", []string{"couplekiss_woman_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman: Medium-Light Skin Tone", []string{"couplekiss_woman_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", "kiss: woman, woman: Medium Skin Tone", []string{"couplekiss_woman_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f42e", "cow face", []string{"cow"}, "6.0", false},
+ {"\U0001f404", "cow", []string{"cow2"}, "6.0", false},
+ {"\U0001f920", "cowboy hat face", []string{"cowboy_hat_face"}, "9.0", false},
+ {"\U0001f980", "crab", []string{"crab"}, "8.0", false},
+ {"\U0001f58d\ufe0f", "crayon", []string{"crayon"}, "7.0", false},
+ {"\U0001f4b3", "credit card", []string{"credit_card"}, "6.0", false},
+ {"\U0001f319", "crescent moon", []string{"crescent_moon"}, "6.0", false},
+ {"\U0001f997", "cricket", []string{"cricket"}, "11.0", false},
+ {"\U0001f3cf", "cricket game", []string{"cricket_game"}, "8.0", false},
+ {"\U0001f1ed\U0001f1f7", "flag: Croatia", []string{"croatia"}, "6.0", false},
+ {"\U0001f40a", "crocodile", []string{"crocodile"}, "6.0", false},
+ {"\U0001f950", "croissant", []string{"croissant"}, "9.0", false},
+ {"\U0001f91e", "crossed fingers", []string{"crossed_fingers"}, "9.0", true},
+ {"\U0001f91e\U0001f3ff", "crossed fingers: Dark Skin Tone", []string{"crossed_fingers_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91e\U0001f3fb", "crossed fingers: Light Skin Tone", []string{"crossed_fingers_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91e\U0001f3fe", "crossed fingers: Medium-Dark Skin Tone", []string{"crossed_fingers_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91e\U0001f3fc", "crossed fingers: Medium-Light Skin Tone", []string{"crossed_fingers_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91e\U0001f3fd", "crossed fingers: Medium Skin Tone", []string{"crossed_fingers_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f38c", "crossed flags", []string{"crossed_flags"}, "6.0", false},
+ {"\u2694\ufe0f", "crossed swords", []string{"crossed_swords"}, "4.1", false},
+ {"\U0001f451", "crown", []string{"crown"}, "6.0", false},
+ {"\U0001fa7c", "crutch", []string{"crutch"}, "14.0", false},
+ {"\U0001f622", "crying face", []string{"cry"}, "6.0", false},
+ {"\U0001f63f", "crying cat", []string{"crying_cat_face"}, "6.0", false},
+ {"\U0001f52e", "crystal ball", []string{"crystal_ball"}, "6.0", false},
+ {"\U0001f1e8\U0001f1fa", "flag: Cuba", []string{"cuba"}, "6.0", false},
+ {"\U0001f952", "cucumber", []string{"cucumber"}, "9.0", false},
+ {"\U0001f964", "cup with straw", []string{"cup_with_straw"}, "11.0", false},
+ {"\U0001f9c1", "cupcake", []string{"cupcake"}, "11.0", false},
+ {"\U0001f498", "heart with arrow", []string{"cupid"}, "6.0", false},
+ {"\U0001f1e8\U0001f1fc", "flag: Curaçao", []string{"curacao"}, "6.0", false},
+ {"\U0001f94c", "curling stone", []string{"curling_stone"}, "11.0", false},
+ {"\U0001f468\u200d\U0001f9b1", "man: curly hair", []string{"curly_haired_man"}, "11.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9b1", "man: curly hair: Dark Skin Tone", []string{"curly_haired_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9b1", "man: curly hair: Light Skin Tone", []string{"curly_haired_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9b1", "man: curly hair: Medium-Dark Skin Tone", []string{"curly_haired_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9b1", "man: curly hair: Medium-Light Skin Tone", []string{"curly_haired_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9b1", "man: curly hair: Medium Skin Tone", []string{"curly_haired_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9b1", "woman: curly hair", []string{"curly_haired_woman"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9b1", "woman: curly hair: Dark Skin Tone", []string{"curly_haired_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9b1", "woman: curly hair: Light Skin Tone", []string{"curly_haired_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9b1", "woman: curly hair: Medium-Dark Skin Tone", []string{"curly_haired_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9b1", "woman: curly hair: Medium-Light Skin Tone", []string{"curly_haired_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9b1", "woman: curly hair: Medium Skin Tone", []string{"curly_haired_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\u27b0", "curly loop", []string{"curly_loop"}, "6.0", false},
+ {"\U0001f4b1", "currency exchange", []string{"currency_exchange"}, "6.0", false},
+ {"\U0001f35b", "curry rice", []string{"curry"}, "6.0", false},
+ {"\U0001f92c", "face with symbols on mouth", []string{"cursing_face"}, "11.0", false},
+ {"\U0001f36e", "custard", []string{"custard"}, "6.0", false},
+ {"\U0001f6c3", "customs", []string{"customs"}, "6.0", false},
+ {"\U0001f969", "cut of meat", []string{"cut_of_meat"}, "11.0", false},
+ {"\U0001f300", "cyclone", []string{"cyclone"}, "6.0", false},
+ {"\U0001f1e8\U0001f1fe", "flag: Cyprus", []string{"cyprus"}, "6.0", false},
+ {"\U0001f1e8\U0001f1ff", "flag: Czechia", []string{"czech_republic"}, "6.0", false},
+ {"\U0001f5e1\ufe0f", "dagger", []string{"dagger"}, "7.0", false},
+ {"\U0001f46f", "people with bunny ears", []string{"dancers"}, "6.0", false},
+ {"\U0001f46f\u200d\u2642\ufe0f", "men with bunny ears", []string{"dancing_men"}, "6.0", false},
+ {"\U0001f46f\u200d\u2640\ufe0f", "women with bunny ears", []string{"dancing_women"}, "11.0", false},
+ {"\U0001f361", "dango", []string{"dango"}, "6.0", false},
+ {"\U0001f576\ufe0f", "sunglasses", []string{"dark_sunglasses"}, "7.0", false},
+ {"\U0001f3af", "bullseye", []string{"dart"}, "6.0", false},
+ {"\U0001f4a8", "dashing away", []string{"dash"}, "6.0", false},
+ {"\U0001f4c5", "calendar", []string{"date"}, "6.0", false},
+ {"\U0001f1e9\U0001f1ea", "flag: Germany", []string{"de"}, "6.0", false},
+ {"\U0001f9cf\u200d\u2642\ufe0f", "deaf man", []string{"deaf_man"}, "12.0", true},
+ {"\U0001f9cf\U0001f3ff\u200d\u2642\ufe0f", "deaf man: Dark Skin Tone", []string{"deaf_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fb\u200d\u2642\ufe0f", "deaf man: Light Skin Tone", []string{"deaf_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fe\u200d\u2642\ufe0f", "deaf man: Medium-Dark Skin Tone", []string{"deaf_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fc\u200d\u2642\ufe0f", "deaf man: Medium-Light Skin Tone", []string{"deaf_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fd\u200d\u2642\ufe0f", "deaf man: Medium Skin Tone", []string{"deaf_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf", "deaf person", []string{"deaf_person"}, "12.0", true},
+ {"\U0001f9cf\U0001f3ff", "deaf person: Dark Skin Tone", []string{"deaf_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fb", "deaf person: Light Skin Tone", []string{"deaf_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fe", "deaf person: Medium-Dark Skin Tone", []string{"deaf_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fc", "deaf person: Medium-Light Skin Tone", []string{"deaf_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fd", "deaf person: Medium Skin Tone", []string{"deaf_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\u200d\u2640\ufe0f", "deaf woman", []string{"deaf_woman"}, "12.0", true},
+ {"\U0001f9cf\U0001f3ff\u200d\u2640\ufe0f", "deaf woman: Dark Skin Tone", []string{"deaf_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fb\u200d\u2640\ufe0f", "deaf woman: Light Skin Tone", []string{"deaf_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fe\u200d\u2640\ufe0f", "deaf woman: Medium-Dark Skin Tone", []string{"deaf_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fc\u200d\u2640\ufe0f", "deaf woman: Medium-Light Skin Tone", []string{"deaf_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cf\U0001f3fd\u200d\u2640\ufe0f", "deaf woman: Medium Skin Tone", []string{"deaf_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f333", "deciduous tree", []string{"deciduous_tree"}, "6.0", false},
+ {"\U0001f98c", "deer", []string{"deer"}, "9.0", false},
+ {"\U0001f1e9\U0001f1f0", "flag: Denmark", []string{"denmark"}, "6.0", false},
+ {"\U0001f3ec", "department store", []string{"department_store"}, "6.0", false},
+ {"\U0001f3da\ufe0f", "derelict house", []string{"derelict_house"}, "7.0", false},
+ {"\U0001f3dc\ufe0f", "desert", []string{"desert"}, "7.0", false},
+ {"\U0001f3dd\ufe0f", "desert island", []string{"desert_island"}, "7.0", false},
+ {"\U0001f5a5\ufe0f", "desktop computer", []string{"desktop_computer"}, "7.0", false},
+ {"\U0001f575\ufe0f", "detective", []string{"detective"}, "7.0", true},
+ {"\U0001f575\U0001f3ff\ufe0f", "detective: Dark Skin Tone", []string{"detective_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fb\ufe0f", "detective: Light Skin Tone", []string{"detective_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fe\ufe0f", "detective: Medium-Dark Skin Tone", []string{"detective_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fc\ufe0f", "detective: Medium-Light Skin Tone", []string{"detective_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fd\ufe0f", "detective: Medium Skin Tone", []string{"detective_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4a0", "diamond with a dot", []string{"diamond_shape_with_a_dot_inside"}, "6.0", false},
+ {"\u2666\ufe0f", "diamond suit", []string{"diamonds"}, "", false},
+ {"\U0001f1e9\U0001f1ec", "flag: Diego Garcia", []string{"diego_garcia"}, "11.0", false},
+ {"\U0001f61e", "disappointed face", []string{"disappointed"}, "6.0", false},
+ {"\U0001f625", "sad but relieved face", []string{"disappointed_relieved"}, "6.0", false},
+ {"\U0001f978", "disguised face", []string{"disguised_face"}, "13.0", false},
+ {"\U0001f93f", "diving mask", []string{"diving_mask"}, "12.0", false},
+ {"\U0001fa94", "diya lamp", []string{"diya_lamp"}, "12.0", false},
+ {"\U0001f4ab", "dizzy", []string{"dizzy"}, "6.0", false},
+ {"\U0001f635", "face with crossed-out eyes", []string{"dizzy_face"}, "6.0", false},
+ {"\U0001f1e9\U0001f1ef", "flag: Djibouti", []string{"djibouti"}, "6.0", false},
+ {"\U0001f9ec", "dna", []string{"dna"}, "11.0", false},
+ {"\U0001f6af", "no littering", []string{"do_not_litter"}, "6.0", false},
+ {"\U0001f9a4", "dodo", []string{"dodo"}, "13.0", false},
+ {"\U0001f436", "dog face", []string{"dog"}, "6.0", false},
+ {"\U0001f415", "dog", []string{"dog2"}, "6.0", false},
+ {"\U0001f4b5", "dollar banknote", []string{"dollar"}, "6.0", false},
+ {"\U0001f38e", "Japanese dolls", []string{"dolls"}, "6.0", false},
+ {"\U0001f42c", "dolphin", []string{"dolphin", "flipper"}, "6.0", false},
+ {"\U0001f1e9\U0001f1f2", "flag: Dominica", []string{"dominica"}, "6.0", false},
+ {"\U0001f1e9\U0001f1f4", "flag: Dominican Republic", []string{"dominican_republic"}, "6.0", false},
+ {"\U0001facf", "donkey", []string{"donkey"}, "15.0", false},
+ {"\U0001f6aa", "door", []string{"door"}, "6.0", false},
+ {"\U0001fae5", "dotted line face", []string{"dotted_line_face"}, "14.0", false},
+ {"\U0001f369", "doughnut", []string{"doughnut"}, "6.0", false},
+ {"\U0001f54a\ufe0f", "dove", []string{"dove"}, "7.0", false},
+ {"\U0001f409", "dragon", []string{"dragon"}, "6.0", false},
+ {"\U0001f432", "dragon face", []string{"dragon_face"}, "6.0", false},
+ {"\U0001f457", "dress", []string{"dress"}, "6.0", false},
+ {"\U0001f42a", "camel", []string{"dromedary_camel"}, "6.0", false},
+ {"\U0001f924", "drooling face", []string{"drooling_face"}, "9.0", false},
+ {"\U0001fa78", "drop of blood", []string{"drop_of_blood"}, "12.0", false},
+ {"\U0001f4a7", "droplet", []string{"droplet"}, "6.0", false},
+ {"\U0001f941", "drum", []string{"drum"}, "", false},
+ {"\U0001f986", "duck", []string{"duck"}, "9.0", false},
+ {"\U0001f95f", "dumpling", []string{"dumpling"}, "11.0", false},
+ {"\U0001f4c0", "dvd", []string{"dvd"}, "6.0", false},
+ {"\U0001f985", "eagle", []string{"eagle"}, "9.0", false},
+ {"\U0001f442", "ear", []string{"ear"}, "6.0", true},
+ {"\U0001f442\U0001f3ff", "ear: Dark Skin Tone", []string{"ear_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f442\U0001f3fb", "ear: Light Skin Tone", []string{"ear_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f442\U0001f3fe", "ear: Medium-Dark Skin Tone", []string{"ear_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f442\U0001f3fc", "ear: Medium-Light Skin Tone", []string{"ear_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f442\U0001f3fd", "ear: Medium Skin Tone", []string{"ear_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f33e", "sheaf of rice", []string{"ear_of_rice"}, "6.0", false},
+ {"\U0001f9bb", "ear with hearing aid", []string{"ear_with_hearing_aid"}, "12.0", true},
+ {"\U0001f9bb\U0001f3ff", "ear with hearing aid: Dark Skin Tone", []string{"ear_with_hearing_aid_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9bb\U0001f3fb", "ear with hearing aid: Light Skin Tone", []string{"ear_with_hearing_aid_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9bb\U0001f3fe", "ear with hearing aid: Medium-Dark Skin Tone", []string{"ear_with_hearing_aid_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9bb\U0001f3fc", "ear with hearing aid: Medium-Light Skin Tone", []string{"ear_with_hearing_aid_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9bb\U0001f3fd", "ear with hearing aid: Medium Skin Tone", []string{"ear_with_hearing_aid_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f30d", "globe showing Europe-Africa", []string{"earth_africa"}, "6.0", false},
+ {"\U0001f30e", "globe showing Americas", []string{"earth_americas"}, "6.0", false},
+ {"\U0001f30f", "globe showing Asia-Australia", []string{"earth_asia"}, "6.0", false},
+ {"\U0001f1ea\U0001f1e8", "flag: Ecuador", []string{"ecuador"}, "6.0", false},
+ {"\U0001f95a", "egg", []string{"egg"}, "9.0", false},
+ {"\U0001f346", "eggplant", []string{"eggplant"}, "6.0", false},
+ {"\U0001f1ea\U0001f1ec", "flag: Egypt", []string{"egypt"}, "6.0", false},
+ {"8\ufe0f\u20e3", "keycap: 8", []string{"eight"}, "", false},
+ {"\u2734\ufe0f", "eight-pointed star", []string{"eight_pointed_black_star"}, "", false},
+ {"\u2733\ufe0f", "eight-spoked asterisk", []string{"eight_spoked_asterisk"}, "", false},
+ {"\u23cf\ufe0f", "eject button", []string{"eject_button"}, "11.0", false},
+ {"\U0001f1f8\U0001f1fb", "flag: El Salvador", []string{"el_salvador"}, "6.0", false},
+ {"\U0001f50c", "electric plug", []string{"electric_plug"}, "6.0", false},
+ {"\U0001f418", "elephant", []string{"elephant"}, "6.0", false},
+ {"\U0001f6d7", "elevator", []string{"elevator"}, "13.0", false},
+ {"\U0001f9dd", "elf", []string{"elf"}, "11.0", true},
+ {"\U0001f9dd\U0001f3ff", "elf: Dark Skin Tone", []string{"elf_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fb", "elf: Light Skin Tone", []string{"elf_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fe", "elf: Medium-Dark Skin Tone", []string{"elf_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fc", "elf: Medium-Light Skin Tone", []string{"elf_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fd", "elf: Medium Skin Tone", []string{"elf_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\u200d\u2642\ufe0f", "man elf", []string{"elf_man"}, "11.0", true},
+ {"\U0001f9dd\U0001f3ff\u200d\u2642\ufe0f", "man elf: Dark Skin Tone", []string{"elf_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fb\u200d\u2642\ufe0f", "man elf: Light Skin Tone", []string{"elf_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fe\u200d\u2642\ufe0f", "man elf: Medium-Dark Skin Tone", []string{"elf_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fc\u200d\u2642\ufe0f", "man elf: Medium-Light Skin Tone", []string{"elf_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fd\u200d\u2642\ufe0f", "man elf: Medium Skin Tone", []string{"elf_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\u200d\u2640\ufe0f", "woman elf", []string{"elf_woman"}, "11.0", true},
+ {"\U0001f9dd\U0001f3ff\u200d\u2640\ufe0f", "woman elf: Dark Skin Tone", []string{"elf_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fb\u200d\u2640\ufe0f", "woman elf: Light Skin Tone", []string{"elf_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fe\u200d\u2640\ufe0f", "woman elf: Medium-Dark Skin Tone", []string{"elf_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fc\u200d\u2640\ufe0f", "woman elf: Medium-Light Skin Tone", []string{"elf_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dd\U0001f3fd\u200d\u2640\ufe0f", "woman elf: Medium Skin Tone", []string{"elf_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4e7", "e-mail", []string{"email", "e-mail"}, "6.0", false},
+ {"\U0001fab9", "empty nest", []string{"empty_nest"}, "14.0", false},
+ {"\U0001f51a", "END arrow", []string{"end"}, "6.0", false},
+ {"\U0001f3f4\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f", "flag: England", []string{"england"}, "11.0", false},
+ {"\u2709\ufe0f", "envelope", []string{"envelope"}, "", false},
+ {"\U0001f4e9", "envelope with arrow", []string{"envelope_with_arrow"}, "6.0", false},
+ {"\U0001f1ec\U0001f1f6", "flag: Equatorial Guinea", []string{"equatorial_guinea"}, "6.0", false},
+ {"\U0001f1ea\U0001f1f7", "flag: Eritrea", []string{"eritrea"}, "6.0", false},
+ {"\U0001f1ea\U0001f1f8", "flag: Spain", []string{"es"}, "6.0", false},
+ {"\U0001f1ea\U0001f1ea", "flag: Estonia", []string{"estonia"}, "6.0", false},
+ {"\U0001f1ea\U0001f1f9", "flag: Ethiopia", []string{"ethiopia"}, "6.0", false},
+ {"\U0001f1ea\U0001f1fa", "flag: European Union", []string{"eu", "european_union"}, "6.0", false},
+ {"\U0001f4b6", "euro banknote", []string{"euro"}, "6.0", false},
+ {"\U0001f3f0", "castle", []string{"european_castle"}, "6.0", false},
+ {"\U0001f3e4", "post office", []string{"european_post_office"}, "6.0", false},
+ {"\U0001f332", "evergreen tree", []string{"evergreen_tree"}, "6.0", false},
+ {"\u2757", "red exclamation mark", []string{"exclamation", "heavy_exclamation_mark"}, "5.2", false},
+ {"\U0001f92f", "exploding head", []string{"exploding_head"}, "11.0", false},
+ {"\U0001f611", "expressionless face", []string{"expressionless"}, "6.1", false},
+ {"\U0001f441\ufe0f", "eye", []string{"eye"}, "7.0", false},
+ {"\U0001f441\ufe0f\u200d\U0001f5e8\ufe0f", "eye in speech bubble", []string{"eye_speech_bubble"}, "11.0", false},
+ {"\U0001f453", "glasses", []string{"eyeglasses"}, "6.0", false},
+ {"\U0001f440", "eyes", []string{"eyes"}, "6.0", false},
+ {"\U0001f62e\u200d\U0001f4a8", "face exhaling", []string{"face_exhaling"}, "13.1", false},
+ {"\U0001f979", "face holding back tears", []string{"face_holding_back_tears"}, "14.0", false},
+ {"\U0001f636\u200d\U0001f32b\ufe0f", "face in clouds", []string{"face_in_clouds"}, "13.1", false},
+ {"\U0001fae4", "face with diagonal mouth", []string{"face_with_diagonal_mouth"}, "14.0", false},
+ {"\U0001f915", "face with head-bandage", []string{"face_with_head_bandage"}, "8.0", false},
+ {"\U0001fae2", "face with open eyes and hand over mouth", []string{"face_with_open_eyes_and_hand_over_mouth"}, "14.0", false},
+ {"\U0001fae3", "face with peeking eye", []string{"face_with_peeking_eye"}, "14.0", false},
+ {"\U0001f635\u200d\U0001f4ab", "face with spiral eyes", []string{"face_with_spiral_eyes"}, "13.1", false},
+ {"\U0001f912", "face with thermometer", []string{"face_with_thermometer"}, "8.0", false},
+ {"\U0001f926", "person facepalming", []string{"facepalm"}, "11.0", true},
+ {"\U0001f926\U0001f3ff", "person facepalming: Dark Skin Tone", []string{"facepalm_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fb", "person facepalming: Light Skin Tone", []string{"facepalm_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fe", "person facepalming: Medium-Dark Skin Tone", []string{"facepalm_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fc", "person facepalming: Medium-Light Skin Tone", []string{"facepalm_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fd", "person facepalming: Medium Skin Tone", []string{"facepalm_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ed", "factory", []string{"factory"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f3ed", "factory worker", []string{"factory_worker"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f3ed", "factory worker: Dark Skin Tone", []string{"factory_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f3ed", "factory worker: Light Skin Tone", []string{"factory_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f3ed", "factory worker: Medium-Dark Skin Tone", []string{"factory_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f3ed", "factory worker: Medium-Light Skin Tone", []string{"factory_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f3ed", "factory worker: Medium Skin Tone", []string{"factory_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da", "fairy", []string{"fairy"}, "11.0", true},
+ {"\U0001f9da\U0001f3ff", "fairy: Dark Skin Tone", []string{"fairy_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fb", "fairy: Light Skin Tone", []string{"fairy_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fe", "fairy: Medium-Dark Skin Tone", []string{"fairy_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fc", "fairy: Medium-Light Skin Tone", []string{"fairy_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fd", "fairy: Medium Skin Tone", []string{"fairy_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\u200d\u2642\ufe0f", "man fairy", []string{"fairy_man"}, "11.0", true},
+ {"\U0001f9da\U0001f3ff\u200d\u2642\ufe0f", "man fairy: Dark Skin Tone", []string{"fairy_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fb\u200d\u2642\ufe0f", "man fairy: Light Skin Tone", []string{"fairy_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fe\u200d\u2642\ufe0f", "man fairy: Medium-Dark Skin Tone", []string{"fairy_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fc\u200d\u2642\ufe0f", "man fairy: Medium-Light Skin Tone", []string{"fairy_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fd\u200d\u2642\ufe0f", "man fairy: Medium Skin Tone", []string{"fairy_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\u200d\u2640\ufe0f", "woman fairy", []string{"fairy_woman"}, "11.0", true},
+ {"\U0001f9da\U0001f3ff\u200d\u2640\ufe0f", "woman fairy: Dark Skin Tone", []string{"fairy_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fb\u200d\u2640\ufe0f", "woman fairy: Light Skin Tone", []string{"fairy_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fe\u200d\u2640\ufe0f", "woman fairy: Medium-Dark Skin Tone", []string{"fairy_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fc\u200d\u2640\ufe0f", "woman fairy: Medium-Light Skin Tone", []string{"fairy_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9da\U0001f3fd\u200d\u2640\ufe0f", "woman fairy: Medium Skin Tone", []string{"fairy_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9c6", "falafel", []string{"falafel"}, "12.0", false},
+ {"\U0001f1eb\U0001f1f0", "flag: Falkland Islands", []string{"falkland_islands"}, "6.0", false},
+ {"\U0001f342", "fallen leaf", []string{"fallen_leaf"}, "6.0", false},
+ {"\U0001f46a", "family", []string{"family"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f466", "family: man, boy", []string{"family_man_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f466\u200d\U0001f466", "family: man, boy, boy", []string{"family_man_boy_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f467", "family: man, girl", []string{"family_man_girl"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f467\u200d\U0001f466", "family: man, girl, boy", []string{"family_man_girl_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f467\u200d\U0001f467", "family: man, girl, girl", []string{"family_man_girl_girl"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f468\u200d\U0001f466", "family: man, man, boy", []string{"family_man_man_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f468\u200d\U0001f466\u200d\U0001f466", "family: man, man, boy, boy", []string{"family_man_man_boy_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f468\u200d\U0001f467", "family: man, man, girl", []string{"family_man_man_girl"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f468\u200d\U0001f467\u200d\U0001f466", "family: man, man, girl, boy", []string{"family_man_man_girl_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f468\u200d\U0001f467\u200d\U0001f467", "family: man, man, girl, girl", []string{"family_man_man_girl_girl"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f469\u200d\U0001f466", "family: man, woman, boy", []string{"family_man_woman_boy"}, "11.0", false},
+ {"\U0001f468\u200d\U0001f469\u200d\U0001f466\u200d\U0001f466", "family: man, woman, boy, boy", []string{"family_man_woman_boy_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f469\u200d\U0001f467", "family: man, woman, girl", []string{"family_man_woman_girl"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466", "family: man, woman, girl, boy", []string{"family_man_woman_girl_boy"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f467", "family: man, woman, girl, girl", []string{"family_man_woman_girl_girl"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f466", "family: woman, boy", []string{"family_woman_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f466\u200d\U0001f466", "family: woman, boy, boy", []string{"family_woman_boy_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f467", "family: woman, girl", []string{"family_woman_girl"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f467\u200d\U0001f466", "family: woman, girl, boy", []string{"family_woman_girl_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f467\u200d\U0001f467", "family: woman, girl, girl", []string{"family_woman_girl_girl"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f469\u200d\U0001f466", "family: woman, woman, boy", []string{"family_woman_woman_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f469\u200d\U0001f466\u200d\U0001f466", "family: woman, woman, boy, boy", []string{"family_woman_woman_boy_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f469\u200d\U0001f467", "family: woman, woman, girl", []string{"family_woman_woman_girl"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466", "family: woman, woman, girl, boy", []string{"family_woman_woman_girl_boy"}, "6.0", false},
+ {"\U0001f469\u200d\U0001f469\u200d\U0001f467\u200d\U0001f467", "family: woman, woman, girl, girl", []string{"family_woman_woman_girl_girl"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f33e", "farmer", []string{"farmer"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f33e", "farmer: Dark Skin Tone", []string{"farmer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f33e", "farmer: Light Skin Tone", []string{"farmer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f33e", "farmer: Medium-Dark Skin Tone", []string{"farmer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f33e", "farmer: Medium-Light Skin Tone", []string{"farmer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f33e", "farmer: Medium Skin Tone", []string{"farmer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1eb\U0001f1f4", "flag: Faroe Islands", []string{"faroe_islands"}, "6.0", false},
+ {"\u23e9", "fast-forward button", []string{"fast_forward"}, "6.0", false},
+ {"\U0001f4e0", "fax machine", []string{"fax"}, "6.0", false},
+ {"\U0001f628", "fearful face", []string{"fearful"}, "6.0", false},
+ {"\U0001fab6", "feather", []string{"feather"}, "13.0", false},
+ {"\U0001f43e", "paw prints", []string{"feet", "paw_prints"}, "6.0", false},
+ {"\U0001f575\ufe0f\u200d\u2640\ufe0f", "woman detective", []string{"female_detective"}, "6.0", true},
+ {"\U0001f575\U0001f3ff\ufe0f\u200d\u2640\ufe0f", "woman detective: Dark Skin Tone", []string{"female_detective_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fb\ufe0f\u200d\u2640\ufe0f", "woman detective: Light Skin Tone", []string{"female_detective_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fe\ufe0f\u200d\u2640\ufe0f", "woman detective: Medium-Dark Skin Tone", []string{"female_detective_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fc\ufe0f\u200d\u2640\ufe0f", "woman detective: Medium-Light Skin Tone", []string{"female_detective_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fd\ufe0f\u200d\u2640\ufe0f", "woman detective: Medium Skin Tone", []string{"female_detective_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2640\ufe0f", "female sign", []string{"female_sign"}, "11.0", false},
+ {"\U0001f3a1", "ferris wheel", []string{"ferris_wheel"}, "6.0", false},
+ {"\u26f4\ufe0f", "ferry", []string{"ferry"}, "5.2", false},
+ {"\U0001f3d1", "field hockey", []string{"field_hockey"}, "8.0", false},
+ {"\U0001f1eb\U0001f1ef", "flag: Fiji", []string{"fiji"}, "6.0", false},
+ {"\U0001f5c4\ufe0f", "file cabinet", []string{"file_cabinet"}, "7.0", false},
+ {"\U0001f4c1", "file folder", []string{"file_folder"}, "6.0", false},
+ {"\U0001f4fd\ufe0f", "film projector", []string{"film_projector"}, "7.0", false},
+ {"\U0001f39e\ufe0f", "film frames", []string{"film_strip"}, "7.0", false},
+ {"\U0001f1eb\U0001f1ee", "flag: Finland", []string{"finland"}, "6.0", false},
+ {"\U0001f525", "fire", []string{"fire"}, "6.0", false},
+ {"\U0001f692", "fire engine", []string{"fire_engine"}, "6.0", false},
+ {"\U0001f9ef", "fire extinguisher", []string{"fire_extinguisher"}, "11.0", false},
+ {"\U0001f9e8", "firecracker", []string{"firecracker"}, "11.0", false},
+ {"\U0001f9d1\u200d\U0001f692", "firefighter", []string{"firefighter"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f692", "firefighter: Dark Skin Tone", []string{"firefighter_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f692", "firefighter: Light Skin Tone", []string{"firefighter_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f692", "firefighter: Medium-Dark Skin Tone", []string{"firefighter_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f692", "firefighter: Medium-Light Skin Tone", []string{"firefighter_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f692", "firefighter: Medium Skin Tone", []string{"firefighter_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f386", "fireworks", []string{"fireworks"}, "6.0", false},
+ {"\U0001f313", "first quarter moon", []string{"first_quarter_moon"}, "6.0", false},
+ {"\U0001f31b", "first quarter moon face", []string{"first_quarter_moon_with_face"}, "6.0", false},
+ {"\U0001f41f", "fish", []string{"fish"}, "6.0", false},
+ {"\U0001f365", "fish cake with swirl", []string{"fish_cake"}, "6.0", false},
+ {"\U0001f3a3", "fishing pole", []string{"fishing_pole_and_fish"}, "6.0", false},
+ {"\U0001f91b", "left-facing fist", []string{"fist_left"}, "9.0", true},
+ {"\U0001f91b\U0001f3ff", "left-facing fist: Dark Skin Tone", []string{"fist_left_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91b\U0001f3fb", "left-facing fist: Light Skin Tone", []string{"fist_left_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91b\U0001f3fe", "left-facing fist: Medium-Dark Skin Tone", []string{"fist_left_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91b\U0001f3fc", "left-facing fist: Medium-Light Skin Tone", []string{"fist_left_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91b\U0001f3fd", "left-facing fist: Medium Skin Tone", []string{"fist_left_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f44a", "oncoming fist", []string{"fist_oncoming", "facepunch", "punch"}, "6.0", true},
+ {"\U0001f44a\U0001f3ff", "oncoming fist: Dark Skin Tone", []string{"fist_oncoming_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44a\U0001f3fb", "oncoming fist: Light Skin Tone", []string{"fist_oncoming_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44a\U0001f3fe", "oncoming fist: Medium-Dark Skin Tone", []string{"fist_oncoming_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44a\U0001f3fc", "oncoming fist: Medium-Light Skin Tone", []string{"fist_oncoming_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44a\U0001f3fd", "oncoming fist: Medium Skin Tone", []string{"fist_oncoming_Medium_Skin_Tone"}, "12.0", false},
+ {"\u270a", "raised fist", []string{"fist_raised", "fist"}, "6.0", true},
+ {"\u270a\U0001f3ff", "raised fist: Dark Skin Tone", []string{"fist_raised_Dark_Skin_Tone"}, "12.0", false},
+ {"\u270a\U0001f3fb", "raised fist: Light Skin Tone", []string{"fist_raised_Light_Skin_Tone"}, "12.0", false},
+ {"\u270a\U0001f3fe", "raised fist: Medium-Dark Skin Tone", []string{"fist_raised_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u270a\U0001f3fc", "raised fist: Medium-Light Skin Tone", []string{"fist_raised_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u270a\U0001f3fd", "raised fist: Medium Skin Tone", []string{"fist_raised_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f91c", "right-facing fist", []string{"fist_right"}, "9.0", true},
+ {"\U0001f91c\U0001f3ff", "right-facing fist: Dark Skin Tone", []string{"fist_right_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91c\U0001f3fb", "right-facing fist: Light Skin Tone", []string{"fist_right_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91c\U0001f3fe", "right-facing fist: Medium-Dark Skin Tone", []string{"fist_right_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91c\U0001f3fc", "right-facing fist: Medium-Light Skin Tone", []string{"fist_right_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91c\U0001f3fd", "right-facing fist: Medium Skin Tone", []string{"fist_right_Medium_Skin_Tone"}, "12.0", false},
+ {"5\ufe0f\u20e3", "keycap: 5", []string{"five"}, "", false},
+ {"\U0001f38f", "carp streamer", []string{"flags"}, "6.0", false},
+ {"\U0001f9a9", "flamingo", []string{"flamingo"}, "12.0", false},
+ {"\U0001f526", "flashlight", []string{"flashlight"}, "6.0", false},
+ {"\U0001f97f", "flat shoe", []string{"flat_shoe"}, "11.0", false},
+ {"\U0001fad3", "flatbread", []string{"flatbread"}, "13.0", false},
+ {"\u269c\ufe0f", "fleur-de-lis", []string{"fleur_de_lis"}, "4.1", false},
+ {"\U0001f6ec", "airplane arrival", []string{"flight_arrival"}, "7.0", false},
+ {"\U0001f6eb", "airplane departure", []string{"flight_departure"}, "7.0", false},
+ {"\U0001f4be", "floppy disk", []string{"floppy_disk"}, "6.0", false},
+ {"\U0001f3b4", "flower playing cards", []string{"flower_playing_cards"}, "6.0", false},
+ {"\U0001f633", "flushed face", []string{"flushed"}, "6.0", false},
+ {"\U0001fa88", "flute", []string{"flute"}, "15.0", false},
+ {"\U0001fab0", "fly", []string{"fly"}, "13.0", false},
+ {"\U0001f94f", "flying disc", []string{"flying_disc"}, "11.0", false},
+ {"\U0001f6f8", "flying saucer", []string{"flying_saucer"}, "11.0", false},
+ {"\U0001f32b\ufe0f", "fog", []string{"fog"}, "7.0", false},
+ {"\U0001f301", "foggy", []string{"foggy"}, "6.0", false},
+ {"\U0001faad", "folding hand fan", []string{"folding_hand_fan"}, "15.0", false},
+ {"\U0001fad5", "fondue", []string{"fondue"}, "13.0", false},
+ {"\U0001f9b6", "foot", []string{"foot"}, "11.0", true},
+ {"\U0001f9b6\U0001f3ff", "foot: Dark Skin Tone", []string{"foot_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b6\U0001f3fb", "foot: Light Skin Tone", []string{"foot_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b6\U0001f3fe", "foot: Medium-Dark Skin Tone", []string{"foot_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b6\U0001f3fc", "foot: Medium-Light Skin Tone", []string{"foot_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b6\U0001f3fd", "foot: Medium Skin Tone", []string{"foot_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c8", "american football", []string{"football"}, "6.0", false},
+ {"\U0001f463", "footprints", []string{"footprints"}, "6.0", false},
+ {"\U0001f374", "fork and knife", []string{"fork_and_knife"}, "6.0", false},
+ {"\U0001f960", "fortune cookie", []string{"fortune_cookie"}, "11.0", false},
+ {"\u26f2", "fountain", []string{"fountain"}, "5.2", false},
+ {"\U0001f58b\ufe0f", "fountain pen", []string{"fountain_pen"}, "7.0", false},
+ {"4\ufe0f\u20e3", "keycap: 4", []string{"four"}, "", false},
+ {"\U0001f340", "four leaf clover", []string{"four_leaf_clover"}, "6.0", false},
+ {"\U0001f98a", "fox", []string{"fox_face"}, "9.0", false},
+ {"\U0001f1eb\U0001f1f7", "flag: France", []string{"fr"}, "6.0", false},
+ {"\U0001f5bc\ufe0f", "framed picture", []string{"framed_picture"}, "7.0", false},
+ {"\U0001f193", "FREE button", []string{"free"}, "6.0", false},
+ {"\U0001f1ec\U0001f1eb", "flag: French Guiana", []string{"french_guiana"}, "6.0", false},
+ {"\U0001f1f5\U0001f1eb", "flag: French Polynesia", []string{"french_polynesia"}, "6.0", false},
+ {"\U0001f1f9\U0001f1eb", "flag: French Southern Territories", []string{"french_southern_territories"}, "6.0", false},
+ {"\U0001f373", "cooking", []string{"fried_egg"}, "6.0", false},
+ {"\U0001f364", "fried shrimp", []string{"fried_shrimp"}, "6.0", false},
+ {"\U0001f35f", "french fries", []string{"fries"}, "6.0", false},
+ {"\U0001f438", "frog", []string{"frog"}, "6.0", false},
+ {"\U0001f626", "frowning face with open mouth", []string{"frowning"}, "6.1", false},
+ {"\u2639\ufe0f", "frowning face", []string{"frowning_face"}, "", false},
+ {"\U0001f64d\u200d\u2642\ufe0f", "man frowning", []string{"frowning_man"}, "6.0", true},
+ {"\U0001f64d\U0001f3ff\u200d\u2642\ufe0f", "man frowning: Dark Skin Tone", []string{"frowning_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fb\u200d\u2642\ufe0f", "man frowning: Light Skin Tone", []string{"frowning_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fe\u200d\u2642\ufe0f", "man frowning: Medium-Dark Skin Tone", []string{"frowning_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fc\u200d\u2642\ufe0f", "man frowning: Medium-Light Skin Tone", []string{"frowning_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fd\u200d\u2642\ufe0f", "man frowning: Medium Skin Tone", []string{"frowning_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d", "person frowning", []string{"frowning_person"}, "6.0", true},
+ {"\U0001f64d\U0001f3ff", "person frowning: Dark Skin Tone", []string{"frowning_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fb", "person frowning: Light Skin Tone", []string{"frowning_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fe", "person frowning: Medium-Dark Skin Tone", []string{"frowning_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fc", "person frowning: Medium-Light Skin Tone", []string{"frowning_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fd", "person frowning: Medium Skin Tone", []string{"frowning_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\u200d\u2640\ufe0f", "woman frowning", []string{"frowning_woman"}, "11.0", true},
+ {"\U0001f64d\U0001f3ff\u200d\u2640\ufe0f", "woman frowning: Dark Skin Tone", []string{"frowning_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fb\u200d\u2640\ufe0f", "woman frowning: Light Skin Tone", []string{"frowning_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fe\u200d\u2640\ufe0f", "woman frowning: Medium-Dark Skin Tone", []string{"frowning_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fc\u200d\u2640\ufe0f", "woman frowning: Medium-Light Skin Tone", []string{"frowning_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64d\U0001f3fd\u200d\u2640\ufe0f", "woman frowning: Medium Skin Tone", []string{"frowning_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\u26fd", "fuel pump", []string{"fuelpump"}, "5.2", false},
+ {"\U0001f315", "full moon", []string{"full_moon"}, "6.0", false},
+ {"\U0001f31d", "full moon face", []string{"full_moon_with_face"}, "6.0", false},
+ {"\u26b1\ufe0f", "funeral urn", []string{"funeral_urn"}, "4.1", false},
+ {"\U0001f1ec\U0001f1e6", "flag: Gabon", []string{"gabon"}, "6.0", false},
+ {"\U0001f1ec\U0001f1f2", "flag: Gambia", []string{"gambia"}, "6.0", false},
+ {"\U0001f3b2", "game die", []string{"game_die"}, "6.0", false},
+ {"\U0001f9c4", "garlic", []string{"garlic"}, "12.0", false},
+ {"\U0001f1ec\U0001f1e7", "flag: United Kingdom", []string{"gb", "uk"}, "6.0", false},
+ {"\u2699\ufe0f", "gear", []string{"gear"}, "4.1", false},
+ {"\U0001f48e", "gem stone", []string{"gem"}, "6.0", false},
+ {"\u264a", "Gemini", []string{"gemini"}, "", false},
+ {"\U0001f9de", "genie", []string{"genie"}, "11.0", false},
+ {"\U0001f9de\u200d\u2642\ufe0f", "man genie", []string{"genie_man"}, "11.0", false},
+ {"\U0001f9de\u200d\u2640\ufe0f", "woman genie", []string{"genie_woman"}, "11.0", false},
+ {"\U0001f1ec\U0001f1ea", "flag: Georgia", []string{"georgia"}, "6.0", false},
+ {"\U0001f1ec\U0001f1ed", "flag: Ghana", []string{"ghana"}, "6.0", false},
+ {"\U0001f47b", "ghost", []string{"ghost"}, "6.0", false},
+ {"\U0001f1ec\U0001f1ee", "flag: Gibraltar", []string{"gibraltar"}, "6.0", false},
+ {"\U0001f381", "wrapped gift", []string{"gift"}, "6.0", false},
+ {"\U0001f49d", "heart with ribbon", []string{"gift_heart"}, "6.0", false},
+ {"\U0001fada", "ginger root", []string{"ginger_root"}, "15.0", false},
+ {"\U0001f992", "giraffe", []string{"giraffe"}, "11.0", false},
+ {"\U0001f467", "girl", []string{"girl"}, "6.0", true},
+ {"\U0001f467\U0001f3ff", "girl: Dark Skin Tone", []string{"girl_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f467\U0001f3fb", "girl: Light Skin Tone", []string{"girl_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f467\U0001f3fe", "girl: Medium-Dark Skin Tone", []string{"girl_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f467\U0001f3fc", "girl: Medium-Light Skin Tone", []string{"girl_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f467\U0001f3fd", "girl: Medium Skin Tone", []string{"girl_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f310", "globe with meridians", []string{"globe_with_meridians"}, "6.0", false},
+ {"\U0001f9e4", "gloves", []string{"gloves"}, "11.0", false},
+ {"\U0001f945", "goal net", []string{"goal_net"}, "9.0", false},
+ {"\U0001f410", "goat", []string{"goat"}, "6.0", false},
+ {"\U0001f97d", "goggles", []string{"goggles"}, "11.0", false},
+ {"\u26f3", "flag in hole", []string{"golf"}, "5.2", false},
+ {"\U0001f3cc\ufe0f", "person golfing", []string{"golfing"}, "7.0", true},
+ {"\U0001f3cc\U0001f3ff\ufe0f", "person golfing: Dark Skin Tone", []string{"golfing_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fb\ufe0f", "person golfing: Light Skin Tone", []string{"golfing_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fe\ufe0f", "person golfing: Medium-Dark Skin Tone", []string{"golfing_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fc\ufe0f", "person golfing: Medium-Light Skin Tone", []string{"golfing_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fd\ufe0f", "person golfing: Medium Skin Tone", []string{"golfing_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\ufe0f\u200d\u2642\ufe0f", "man golfing", []string{"golfing_man"}, "11.0", true},
+ {"\U0001f3cc\U0001f3ff\ufe0f\u200d\u2642\ufe0f", "man golfing: Dark Skin Tone", []string{"golfing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fb\ufe0f\u200d\u2642\ufe0f", "man golfing: Light Skin Tone", []string{"golfing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fe\ufe0f\u200d\u2642\ufe0f", "man golfing: Medium-Dark Skin Tone", []string{"golfing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fc\ufe0f\u200d\u2642\ufe0f", "man golfing: Medium-Light Skin Tone", []string{"golfing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fd\ufe0f\u200d\u2642\ufe0f", "man golfing: Medium Skin Tone", []string{"golfing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\ufe0f\u200d\u2640\ufe0f", "woman golfing", []string{"golfing_woman"}, "", true},
+ {"\U0001f3cc\U0001f3ff\ufe0f\u200d\u2640\ufe0f", "woman golfing: Dark Skin Tone", []string{"golfing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fb\ufe0f\u200d\u2640\ufe0f", "woman golfing: Light Skin Tone", []string{"golfing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fe\ufe0f\u200d\u2640\ufe0f", "woman golfing: Medium-Dark Skin Tone", []string{"golfing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fc\ufe0f\u200d\u2640\ufe0f", "woman golfing: Medium-Light Skin Tone", []string{"golfing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cc\U0001f3fd\ufe0f\u200d\u2640\ufe0f", "woman golfing: Medium Skin Tone", []string{"golfing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fabf", "goose", []string{"goose"}, "15.0", false},
+ {"\U0001f98d", "gorilla", []string{"gorilla"}, "9.0", false},
+ {"\U0001f347", "grapes", []string{"grapes"}, "6.0", false},
+ {"\U0001f1ec\U0001f1f7", "flag: Greece", []string{"greece"}, "6.0", false},
+ {"\U0001f34f", "green apple", []string{"green_apple"}, "6.0", false},
+ {"\U0001f4d7", "green book", []string{"green_book"}, "6.0", false},
+ {"\U0001f7e2", "green circle", []string{"green_circle"}, "12.0", false},
+ {"\U0001f49a", "green heart", []string{"green_heart"}, "6.0", false},
+ {"\U0001f957", "green salad", []string{"green_salad"}, "9.0", false},
+ {"\U0001f7e9", "green square", []string{"green_square"}, "12.0", false},
+ {"\U0001f1ec\U0001f1f1", "flag: Greenland", []string{"greenland"}, "6.0", false},
+ {"\U0001f1ec\U0001f1e9", "flag: Grenada", []string{"grenada"}, "6.0", false},
+ {"\u2755", "white exclamation mark", []string{"grey_exclamation"}, "6.0", false},
+ {"\U0001fa76", "grey heart", []string{"grey_heart"}, "15.0", false},
+ {"\u2754", "white question mark", []string{"grey_question"}, "6.0", false},
+ {"\U0001f62c", "grimacing face", []string{"grimacing"}, "6.1", false},
+ {"\U0001f601", "beaming face with smiling eyes", []string{"grin"}, "6.0", false},
+ {"\U0001f600", "grinning face", []string{"grinning"}, "6.1", false},
+ {"\U0001f1ec\U0001f1f5", "flag: Guadeloupe", []string{"guadeloupe"}, "6.0", false},
+ {"\U0001f1ec\U0001f1fa", "flag: Guam", []string{"guam"}, "6.0", false},
+ {"\U0001f482", "guard", []string{"guard"}, "6.0", true},
+ {"\U0001f482\U0001f3ff", "guard: Dark Skin Tone", []string{"guard_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fb", "guard: Light Skin Tone", []string{"guard_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fe", "guard: Medium-Dark Skin Tone", []string{"guard_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fc", "guard: Medium-Light Skin Tone", []string{"guard_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fd", "guard: Medium Skin Tone", []string{"guard_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\u200d\u2642\ufe0f", "man guard", []string{"guardsman"}, "11.0", true},
+ {"\U0001f482\U0001f3ff\u200d\u2642\ufe0f", "man guard: Dark Skin Tone", []string{"guardsman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fb\u200d\u2642\ufe0f", "man guard: Light Skin Tone", []string{"guardsman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fe\u200d\u2642\ufe0f", "man guard: Medium-Dark Skin Tone", []string{"guardsman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fc\u200d\u2642\ufe0f", "man guard: Medium-Light Skin Tone", []string{"guardsman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fd\u200d\u2642\ufe0f", "man guard: Medium Skin Tone", []string{"guardsman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\u200d\u2640\ufe0f", "woman guard", []string{"guardswoman"}, "6.0", true},
+ {"\U0001f482\U0001f3ff\u200d\u2640\ufe0f", "woman guard: Dark Skin Tone", []string{"guardswoman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fb\u200d\u2640\ufe0f", "woman guard: Light Skin Tone", []string{"guardswoman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fe\u200d\u2640\ufe0f", "woman guard: Medium-Dark Skin Tone", []string{"guardswoman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fc\u200d\u2640\ufe0f", "woman guard: Medium-Light Skin Tone", []string{"guardswoman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f482\U0001f3fd\u200d\u2640\ufe0f", "woman guard: Medium Skin Tone", []string{"guardswoman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1ec\U0001f1f9", "flag: Guatemala", []string{"guatemala"}, "6.0", false},
+ {"\U0001f1ec\U0001f1ec", "flag: Guernsey", []string{"guernsey"}, "6.0", false},
+ {"\U0001f9ae", "guide dog", []string{"guide_dog"}, "12.0", false},
+ {"\U0001f1ec\U0001f1f3", "flag: Guinea", []string{"guinea"}, "6.0", false},
+ {"\U0001f1ec\U0001f1fc", "flag: Guinea-Bissau", []string{"guinea_bissau"}, "6.0", false},
+ {"\U0001f3b8", "guitar", []string{"guitar"}, "6.0", false},
+ {"\U0001f52b", "water pistol", []string{"gun"}, "6.0", false},
+ {"\U0001f1ec\U0001f1fe", "flag: Guyana", []string{"guyana"}, "6.0", false},
+ {"\U0001faae", "hair pick", []string{"hair_pick"}, "15.0", false},
+ {"\U0001f487", "person getting haircut", []string{"haircut"}, "6.0", true},
+ {"\U0001f487\U0001f3ff", "person getting haircut: Dark Skin Tone", []string{"haircut_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fb", "person getting haircut: Light Skin Tone", []string{"haircut_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fe", "person getting haircut: Medium-Dark Skin Tone", []string{"haircut_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fc", "person getting haircut: Medium-Light Skin Tone", []string{"haircut_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fd", "person getting haircut: Medium Skin Tone", []string{"haircut_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\u200d\u2642\ufe0f", "man getting haircut", []string{"haircut_man"}, "6.0", true},
+ {"\U0001f487\U0001f3ff\u200d\u2642\ufe0f", "man getting haircut: Dark Skin Tone", []string{"haircut_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fb\u200d\u2642\ufe0f", "man getting haircut: Light Skin Tone", []string{"haircut_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fe\u200d\u2642\ufe0f", "man getting haircut: Medium-Dark Skin Tone", []string{"haircut_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fc\u200d\u2642\ufe0f", "man getting haircut: Medium-Light Skin Tone", []string{"haircut_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fd\u200d\u2642\ufe0f", "man getting haircut: Medium Skin Tone", []string{"haircut_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\u200d\u2640\ufe0f", "woman getting haircut", []string{"haircut_woman"}, "11.0", true},
+ {"\U0001f487\U0001f3ff\u200d\u2640\ufe0f", "woman getting haircut: Dark Skin Tone", []string{"haircut_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fb\u200d\u2640\ufe0f", "woman getting haircut: Light Skin Tone", []string{"haircut_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fe\u200d\u2640\ufe0f", "woman getting haircut: Medium-Dark Skin Tone", []string{"haircut_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fc\u200d\u2640\ufe0f", "woman getting haircut: Medium-Light Skin Tone", []string{"haircut_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f487\U0001f3fd\u200d\u2640\ufe0f", "woman getting haircut: Medium Skin Tone", []string{"haircut_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1ed\U0001f1f9", "flag: Haiti", []string{"haiti"}, "6.0", false},
+ {"\U0001f354", "hamburger", []string{"hamburger"}, "6.0", false},
+ {"\U0001f528", "hammer", []string{"hammer"}, "6.0", false},
+ {"\u2692\ufe0f", "hammer and pick", []string{"hammer_and_pick"}, "4.1", false},
+ {"\U0001f6e0\ufe0f", "hammer and wrench", []string{"hammer_and_wrench"}, "7.0", false},
+ {"\U0001faac", "hamsa", []string{"hamsa"}, "14.0", false},
+ {"\U0001f439", "hamster", []string{"hamster"}, "6.0", false},
+ {"\u270b", "raised hand", []string{"hand", "raised_hand"}, "6.0", true},
+ {"\u270b\U0001f3ff", "raised hand: Dark Skin Tone", []string{"hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\u270b\U0001f3fb", "raised hand: Light Skin Tone", []string{"hand_Light_Skin_Tone"}, "12.0", false},
+ {"\u270b\U0001f3fe", "raised hand: Medium-Dark Skin Tone", []string{"hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u270b\U0001f3fc", "raised hand: Medium-Light Skin Tone", []string{"hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u270b\U0001f3fd", "raised hand: Medium Skin Tone", []string{"hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f92d", "face with hand over mouth", []string{"hand_over_mouth"}, "11.0", false},
+ {"\U0001faf0", "hand with index finger and thumb crossed", []string{"hand_with_index_finger_and_thumb_crossed"}, "14.0", true},
+ {"\U0001faf0\U0001f3ff", "hand with index finger and thumb crossed: Dark Skin Tone", []string{"hand_with_index_finger_and_thumb_crossed_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf0\U0001f3fb", "hand with index finger and thumb crossed: Light Skin Tone", []string{"hand_with_index_finger_and_thumb_crossed_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf0\U0001f3fe", "hand with index finger and thumb crossed: Medium-Dark Skin Tone", []string{"hand_with_index_finger_and_thumb_crossed_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf0\U0001f3fc", "hand with index finger and thumb crossed: Medium-Light Skin Tone", []string{"hand_with_index_finger_and_thumb_crossed_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf0\U0001f3fd", "hand with index finger and thumb crossed: Medium Skin Tone", []string{"hand_with_index_finger_and_thumb_crossed_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f45c", "handbag", []string{"handbag"}, "6.0", false},
+ {"\U0001f93e", "person playing handball", []string{"handball_person"}, "11.0", true},
+ {"\U0001f93e\U0001f3ff", "person playing handball: Dark Skin Tone", []string{"handball_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fb", "person playing handball: Light Skin Tone", []string{"handball_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fe", "person playing handball: Medium-Dark Skin Tone", []string{"handball_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fc", "person playing handball: Medium-Light Skin Tone", []string{"handball_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fd", "person playing handball: Medium Skin Tone", []string{"handball_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f91d", "handshake", []string{"handshake"}, "9.0", true},
+ {"\U0001f91d\U0001f3ff", "handshake: Dark Skin Tone", []string{"handshake_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91d\U0001f3fb", "handshake: Light Skin Tone", []string{"handshake_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91d\U0001f3fe", "handshake: Medium-Dark Skin Tone", []string{"handshake_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91d\U0001f3fc", "handshake: Medium-Light Skin Tone", []string{"handshake_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91d\U0001f3fd", "handshake: Medium Skin Tone", []string{"handshake_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4a9", "pile of poo", []string{"hankey", "poop", "shit"}, "6.0", false},
+ {"#\ufe0f\u20e3", "keycap: #", []string{"hash"}, "", false},
+ {"\U0001f425", "front-facing baby chick", []string{"hatched_chick"}, "6.0", false},
+ {"\U0001f423", "hatching chick", []string{"hatching_chick"}, "6.0", false},
+ {"\U0001f3a7", "headphone", []string{"headphones"}, "6.0", false},
+ {"\U0001faa6", "headstone", []string{"headstone"}, "13.0", false},
+ {"\U0001f9d1\u200d\u2695\ufe0f", "health worker", []string{"health_worker"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\u2695\ufe0f", "health worker: Dark Skin Tone", []string{"health_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\u2695\ufe0f", "health worker: Light Skin Tone", []string{"health_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\u2695\ufe0f", "health worker: Medium-Dark Skin Tone", []string{"health_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\u2695\ufe0f", "health worker: Medium-Light Skin Tone", []string{"health_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\u2695\ufe0f", "health worker: Medium Skin Tone", []string{"health_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f649", "hear-no-evil monkey", []string{"hear_no_evil"}, "6.0", false},
+ {"\U0001f1ed\U0001f1f2", "flag: Heard & McDonald Islands", []string{"heard_mcdonald_islands"}, "11.0", false},
+ {"\u2764\ufe0f", "red heart", []string{"heart"}, "", false},
+ {"\U0001f49f", "heart decoration", []string{"heart_decoration"}, "6.0", false},
+ {"\U0001f60d", "smiling face with heart-eyes", []string{"heart_eyes"}, "6.0", false},
+ {"\U0001f63b", "smiling cat with heart-eyes", []string{"heart_eyes_cat"}, "6.0", false},
+ {"\U0001faf6", "heart hands", []string{"heart_hands"}, "14.0", true},
+ {"\U0001faf6\U0001f3ff", "heart hands: Dark Skin Tone", []string{"heart_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf6\U0001f3fb", "heart hands: Light Skin Tone", []string{"heart_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf6\U0001f3fe", "heart hands: Medium-Dark Skin Tone", []string{"heart_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf6\U0001f3fc", "heart hands: Medium-Light Skin Tone", []string{"heart_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf6\U0001f3fd", "heart hands: Medium Skin Tone", []string{"heart_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2764\ufe0f\u200d\U0001f525", "heart on fire", []string{"heart_on_fire"}, "13.1", false},
+ {"\U0001f493", "beating heart", []string{"heartbeat"}, "6.0", false},
+ {"\U0001f497", "growing heart", []string{"heartpulse"}, "6.0", false},
+ {"\u2665\ufe0f", "heart suit", []string{"hearts"}, "", false},
+ {"\u2714\ufe0f", "check mark", []string{"heavy_check_mark"}, "", false},
+ {"\u2797", "divide", []string{"heavy_division_sign"}, "6.0", false},
+ {"\U0001f4b2", "heavy dollar sign", []string{"heavy_dollar_sign"}, "6.0", false},
+ {"\U0001f7f0", "heavy equals sign", []string{"heavy_equals_sign"}, "14.0", false},
+ {"\u2763\ufe0f", "heart exclamation", []string{"heavy_heart_exclamation"}, "", false},
+ {"\u2796", "minus", []string{"heavy_minus_sign"}, "6.0", false},
+ {"\u2716\ufe0f", "multiply", []string{"heavy_multiplication_x"}, "", false},
+ {"\u2795", "plus", []string{"heavy_plus_sign"}, "6.0", false},
+ {"\U0001f994", "hedgehog", []string{"hedgehog"}, "11.0", false},
+ {"\U0001f681", "helicopter", []string{"helicopter"}, "6.0", false},
+ {"\U0001f33f", "herb", []string{"herb"}, "6.0", false},
+ {"\U0001f33a", "hibiscus", []string{"hibiscus"}, "6.0", false},
+ {"\U0001f506", "bright button", []string{"high_brightness"}, "6.0", false},
+ {"\U0001f460", "high-heeled shoe", []string{"high_heel"}, "6.0", false},
+ {"\U0001f97e", "hiking boot", []string{"hiking_boot"}, "11.0", false},
+ {"\U0001f6d5", "hindu temple", []string{"hindu_temple"}, "12.0", false},
+ {"\U0001f99b", "hippopotamus", []string{"hippopotamus"}, "11.0", false},
+ {"\U0001f52a", "kitchen knife", []string{"hocho", "knife"}, "6.0", false},
+ {"\U0001f573\ufe0f", "hole", []string{"hole"}, "7.0", false},
+ {"\U0001f1ed\U0001f1f3", "flag: Honduras", []string{"honduras"}, "6.0", false},
+ {"\U0001f36f", "honey pot", []string{"honey_pot"}, "6.0", false},
+ {"\U0001f1ed\U0001f1f0", "flag: Hong Kong SAR China", []string{"hong_kong"}, "6.0", false},
+ {"\U0001fa9d", "hook", []string{"hook"}, "13.0", false},
+ {"\U0001f434", "horse face", []string{"horse"}, "6.0", false},
+ {"\U0001f3c7", "horse racing", []string{"horse_racing"}, "6.0", true},
+ {"\U0001f3c7\U0001f3ff", "horse racing: Dark Skin Tone", []string{"horse_racing_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c7\U0001f3fb", "horse racing: Light Skin Tone", []string{"horse_racing_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c7\U0001f3fe", "horse racing: Medium-Dark Skin Tone", []string{"horse_racing_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c7\U0001f3fc", "horse racing: Medium-Light Skin Tone", []string{"horse_racing_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c7\U0001f3fd", "horse racing: Medium Skin Tone", []string{"horse_racing_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3e5", "hospital", []string{"hospital"}, "6.0", false},
+ {"\U0001f975", "hot face", []string{"hot_face"}, "11.0", false},
+ {"\U0001f336\ufe0f", "hot pepper", []string{"hot_pepper"}, "7.0", false},
+ {"\U0001f32d", "hot dog", []string{"hotdog"}, "8.0", false},
+ {"\U0001f3e8", "hotel", []string{"hotel"}, "6.0", false},
+ {"\u2668\ufe0f", "hot springs", []string{"hotsprings"}, "", false},
+ {"\u231b", "hourglass done", []string{"hourglass"}, "", false},
+ {"\u23f3", "hourglass not done", []string{"hourglass_flowing_sand"}, "6.0", false},
+ {"\U0001f3e0", "house", []string{"house"}, "6.0", false},
+ {"\U0001f3e1", "house with garden", []string{"house_with_garden"}, "6.0", false},
+ {"\U0001f3d8\ufe0f", "houses", []string{"houses"}, "7.0", false},
+ {"\U0001f917", "smiling face with open hands", []string{"hugs"}, "8.0", false},
+ {"\U0001f1ed\U0001f1fa", "flag: Hungary", []string{"hungary"}, "6.0", false},
+ {"\U0001f62f", "hushed face", []string{"hushed"}, "6.1", false},
+ {"\U0001f6d6", "hut", []string{"hut"}, "13.0", false},
+ {"\U0001fabb", "hyacinth", []string{"hyacinth"}, "15.0", false},
+ {"\U0001f368", "ice cream", []string{"ice_cream"}, "6.0", false},
+ {"\U0001f9ca", "ice", []string{"ice_cube"}, "12.0", false},
+ {"\U0001f3d2", "ice hockey", []string{"ice_hockey"}, "8.0", false},
+ {"\u26f8\ufe0f", "ice skate", []string{"ice_skate"}, "5.2", false},
+ {"\U0001f366", "soft ice cream", []string{"icecream"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f8", "flag: Iceland", []string{"iceland"}, "6.0", false},
+ {"\U0001f194", "ID button", []string{"id"}, "6.0", false},
+ {"\U0001faaa", "identification card", []string{"identification_card"}, "14.0", false},
+ {"\U0001f250", "Japanese “bargain†button", []string{"ideograph_advantage"}, "6.0", false},
+ {"\U0001f47f", "angry face with horns", []string{"imp"}, "6.0", false},
+ {"\U0001f4e5", "inbox tray", []string{"inbox_tray"}, "6.0", false},
+ {"\U0001f4e8", "incoming envelope", []string{"incoming_envelope"}, "6.0", false},
+ {"\U0001faf5", "index pointing at the viewer", []string{"index_pointing_at_the_viewer"}, "14.0", true},
+ {"\U0001faf5\U0001f3ff", "index pointing at the viewer: Dark Skin Tone", []string{"index_pointing_at_the_viewer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf5\U0001f3fb", "index pointing at the viewer: Light Skin Tone", []string{"index_pointing_at_the_viewer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf5\U0001f3fe", "index pointing at the viewer: Medium-Dark Skin Tone", []string{"index_pointing_at_the_viewer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf5\U0001f3fc", "index pointing at the viewer: Medium-Light Skin Tone", []string{"index_pointing_at_the_viewer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf5\U0001f3fd", "index pointing at the viewer: Medium Skin Tone", []string{"index_pointing_at_the_viewer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1ee\U0001f1f3", "flag: India", []string{"india"}, "6.0", false},
+ {"\U0001f1ee\U0001f1e9", "flag: Indonesia", []string{"indonesia"}, "6.0", false},
+ {"\u267e\ufe0f", "infinity", []string{"infinity"}, "11.0", false},
+ {"\u2139\ufe0f", "information", []string{"information_source"}, "3.0", false},
+ {"\U0001f607", "smiling face with halo", []string{"innocent"}, "6.0", false},
+ {"\u2049\ufe0f", "exclamation question mark", []string{"interrobang"}, "3.0", false},
+ {"\U0001f4f1", "mobile phone", []string{"iphone"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f7", "flag: Iran", []string{"iran"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f6", "flag: Iraq", []string{"iraq"}, "6.0", false},
+ {"\U0001f1ee\U0001f1ea", "flag: Ireland", []string{"ireland"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f2", "flag: Isle of Man", []string{"isle_of_man"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f1", "flag: Israel", []string{"israel"}, "6.0", false},
+ {"\U0001f1ee\U0001f1f9", "flag: Italy", []string{"it"}, "6.0", false},
+ {"\U0001f3ee", "red paper lantern", []string{"izakaya_lantern", "lantern"}, "6.0", false},
+ {"\U0001f383", "jack-o-lantern", []string{"jack_o_lantern"}, "6.0", false},
+ {"\U0001f1ef\U0001f1f2", "flag: Jamaica", []string{"jamaica"}, "6.0", false},
+ {"\U0001f5fe", "map of Japan", []string{"japan"}, "6.0", false},
+ {"\U0001f3ef", "Japanese castle", []string{"japanese_castle"}, "6.0", false},
+ {"\U0001f47a", "goblin", []string{"japanese_goblin"}, "6.0", false},
+ {"\U0001f479", "ogre", []string{"japanese_ogre"}, "6.0", false},
+ {"\U0001fad9", "jar", []string{"jar"}, "14.0", false},
+ {"\U0001f456", "jeans", []string{"jeans"}, "6.0", false},
+ {"\U0001fabc", "jellyfish", []string{"jellyfish"}, "15.0", false},
+ {"\U0001f1ef\U0001f1ea", "flag: Jersey", []string{"jersey"}, "6.0", false},
+ {"\U0001f9e9", "puzzle piece", []string{"jigsaw"}, "11.0", false},
+ {"\U0001f1ef\U0001f1f4", "flag: Jordan", []string{"jordan"}, "6.0", false},
+ {"\U0001f602", "face with tears of joy", []string{"joy"}, "6.0", false},
+ {"\U0001f639", "cat with tears of joy", []string{"joy_cat"}, "6.0", false},
+ {"\U0001f579\ufe0f", "joystick", []string{"joystick"}, "7.0", false},
+ {"\U0001f1ef\U0001f1f5", "flag: Japan", []string{"jp"}, "6.0", false},
+ {"\U0001f9d1\u200d\u2696\ufe0f", "judge", []string{"judge"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\u2696\ufe0f", "judge: Dark Skin Tone", []string{"judge_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\u2696\ufe0f", "judge: Light Skin Tone", []string{"judge_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\u2696\ufe0f", "judge: Medium-Dark Skin Tone", []string{"judge_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\u2696\ufe0f", "judge: Medium-Light Skin Tone", []string{"judge_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\u2696\ufe0f", "judge: Medium Skin Tone", []string{"judge_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f939", "person juggling", []string{"juggling_person"}, "11.0", true},
+ {"\U0001f939\U0001f3ff", "person juggling: Dark Skin Tone", []string{"juggling_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fb", "person juggling: Light Skin Tone", []string{"juggling_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fe", "person juggling: Medium-Dark Skin Tone", []string{"juggling_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fc", "person juggling: Medium-Light Skin Tone", []string{"juggling_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fd", "person juggling: Medium Skin Tone", []string{"juggling_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f54b", "kaaba", []string{"kaaba"}, "8.0", false},
+ {"\U0001f998", "kangaroo", []string{"kangaroo"}, "11.0", false},
+ {"\U0001f1f0\U0001f1ff", "flag: Kazakhstan", []string{"kazakhstan"}, "6.0", false},
+ {"\U0001f1f0\U0001f1ea", "flag: Kenya", []string{"kenya"}, "6.0", false},
+ {"\U0001f511", "key", []string{"key"}, "6.0", false},
+ {"\u2328\ufe0f", "keyboard", []string{"keyboard"}, "", false},
+ {"\U0001f51f", "keycap: 10", []string{"keycap_ten"}, "6.0", false},
+ {"\U0001faaf", "khanda", []string{"khanda"}, "15.0", false},
+ {"\U0001f6f4", "kick scooter", []string{"kick_scooter"}, "9.0", false},
+ {"\U0001f458", "kimono", []string{"kimono"}, "6.0", false},
+ {"\U0001f1f0\U0001f1ee", "flag: Kiribati", []string{"kiribati"}, "6.0", false},
+ {"\U0001f48b", "kiss mark", []string{"kiss"}, "6.0", false},
+ {"\U0001f617", "kissing face", []string{"kissing"}, "6.1", false},
+ {"\U0001f63d", "kissing cat", []string{"kissing_cat"}, "6.0", false},
+ {"\U0001f61a", "kissing face with closed eyes", []string{"kissing_closed_eyes"}, "6.0", false},
+ {"\U0001f618", "face blowing a kiss", []string{"kissing_heart"}, "6.0", false},
+ {"\U0001f619", "kissing face with smiling eyes", []string{"kissing_smiling_eyes"}, "6.1", false},
+ {"\U0001fa81", "kite", []string{"kite"}, "12.0", false},
+ {"\U0001f95d", "kiwi fruit", []string{"kiwi_fruit"}, "9.0", false},
+ {"\U0001f9ce\u200d\u2642\ufe0f", "man kneeling", []string{"kneeling_man"}, "12.0", true},
+ {"\U0001f9ce\U0001f3ff\u200d\u2642\ufe0f", "man kneeling: Dark Skin Tone", []string{"kneeling_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fb\u200d\u2642\ufe0f", "man kneeling: Light Skin Tone", []string{"kneeling_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fe\u200d\u2642\ufe0f", "man kneeling: Medium-Dark Skin Tone", []string{"kneeling_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fc\u200d\u2642\ufe0f", "man kneeling: Medium-Light Skin Tone", []string{"kneeling_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fd\u200d\u2642\ufe0f", "man kneeling: Medium Skin Tone", []string{"kneeling_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce", "person kneeling", []string{"kneeling_person"}, "12.0", true},
+ {"\U0001f9ce\U0001f3ff", "person kneeling: Dark Skin Tone", []string{"kneeling_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fb", "person kneeling: Light Skin Tone", []string{"kneeling_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fe", "person kneeling: Medium-Dark Skin Tone", []string{"kneeling_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fc", "person kneeling: Medium-Light Skin Tone", []string{"kneeling_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fd", "person kneeling: Medium Skin Tone", []string{"kneeling_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\u200d\u2640\ufe0f", "woman kneeling", []string{"kneeling_woman"}, "12.0", true},
+ {"\U0001f9ce\U0001f3ff\u200d\u2640\ufe0f", "woman kneeling: Dark Skin Tone", []string{"kneeling_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fb\u200d\u2640\ufe0f", "woman kneeling: Light Skin Tone", []string{"kneeling_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fe\u200d\u2640\ufe0f", "woman kneeling: Medium-Dark Skin Tone", []string{"kneeling_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fc\u200d\u2640\ufe0f", "woman kneeling: Medium-Light Skin Tone", []string{"kneeling_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9ce\U0001f3fd\u200d\u2640\ufe0f", "woman kneeling: Medium Skin Tone", []string{"kneeling_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001faa2", "knot", []string{"knot"}, "13.0", false},
+ {"\U0001f428", "koala", []string{"koala"}, "6.0", false},
+ {"\U0001f201", "Japanese “here†button", []string{"koko"}, "6.0", false},
+ {"\U0001f1fd\U0001f1f0", "flag: Kosovo", []string{"kosovo"}, "6.0", false},
+ {"\U0001f1f0\U0001f1f7", "flag: South Korea", []string{"kr"}, "6.0", false},
+ {"\U0001f1f0\U0001f1fc", "flag: Kuwait", []string{"kuwait"}, "6.0", false},
+ {"\U0001f1f0\U0001f1ec", "flag: Kyrgyzstan", []string{"kyrgyzstan"}, "6.0", false},
+ {"\U0001f97c", "lab coat", []string{"lab_coat"}, "11.0", false},
+ {"\U0001f3f7\ufe0f", "label", []string{"label"}, "7.0", false},
+ {"\U0001f94d", "lacrosse", []string{"lacrosse"}, "11.0", false},
+ {"\U0001fa9c", "ladder", []string{"ladder"}, "13.0", false},
+ {"\U0001f41e", "lady beetle", []string{"lady_beetle"}, "6.0", false},
+ {"\U0001f1f1\U0001f1e6", "flag: Laos", []string{"laos"}, "6.0", false},
+ {"\U0001f535", "blue circle", []string{"large_blue_circle"}, "6.0", false},
+ {"\U0001f537", "large blue diamond", []string{"large_blue_diamond"}, "6.0", false},
+ {"\U0001f536", "large orange diamond", []string{"large_orange_diamond"}, "6.0", false},
+ {"\U0001f317", "last quarter moon", []string{"last_quarter_moon"}, "6.0", false},
+ {"\U0001f31c", "last quarter moon face", []string{"last_quarter_moon_with_face"}, "6.0", false},
+ {"\u271d\ufe0f", "latin cross", []string{"latin_cross"}, "", false},
+ {"\U0001f1f1\U0001f1fb", "flag: Latvia", []string{"latvia"}, "6.0", false},
+ {"\U0001f606", "grinning squinting face", []string{"laughing", "satisfied", "laugh"}, "6.0", false},
+ {"\U0001f96c", "leafy green", []string{"leafy_green"}, "11.0", false},
+ {"\U0001f343", "leaf fluttering in wind", []string{"leaves"}, "6.0", false},
+ {"\U0001f1f1\U0001f1e7", "flag: Lebanon", []string{"lebanon"}, "6.0", false},
+ {"\U0001f4d2", "ledger", []string{"ledger"}, "6.0", false},
+ {"\U0001f6c5", "left luggage", []string{"left_luggage"}, "6.0", false},
+ {"\u2194\ufe0f", "left-right arrow", []string{"left_right_arrow"}, "", false},
+ {"\U0001f5e8\ufe0f", "left speech bubble", []string{"left_speech_bubble"}, "11.0", false},
+ {"\u21a9\ufe0f", "right arrow curving left", []string{"leftwards_arrow_with_hook"}, "", false},
+ {"\U0001faf2", "leftwards hand", []string{"leftwards_hand"}, "14.0", true},
+ {"\U0001faf2\U0001f3ff", "leftwards hand: Dark Skin Tone", []string{"leftwards_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf2\U0001f3fb", "leftwards hand: Light Skin Tone", []string{"leftwards_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf2\U0001f3fe", "leftwards hand: Medium-Dark Skin Tone", []string{"leftwards_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf2\U0001f3fc", "leftwards hand: Medium-Light Skin Tone", []string{"leftwards_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf2\U0001f3fd", "leftwards hand: Medium Skin Tone", []string{"leftwards_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001faf7", "leftwards pushing hand", []string{"leftwards_pushing_hand"}, "15.0", true},
+ {"\U0001faf7\U0001f3ff", "leftwards pushing hand: Dark Skin Tone", []string{"leftwards_pushing_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf7\U0001f3fb", "leftwards pushing hand: Light Skin Tone", []string{"leftwards_pushing_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf7\U0001f3fe", "leftwards pushing hand: Medium-Dark Skin Tone", []string{"leftwards_pushing_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf7\U0001f3fc", "leftwards pushing hand: Medium-Light Skin Tone", []string{"leftwards_pushing_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf7\U0001f3fd", "leftwards pushing hand: Medium Skin Tone", []string{"leftwards_pushing_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b5", "leg", []string{"leg"}, "11.0", true},
+ {"\U0001f9b5\U0001f3ff", "leg: Dark Skin Tone", []string{"leg_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b5\U0001f3fb", "leg: Light Skin Tone", []string{"leg_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b5\U0001f3fe", "leg: Medium-Dark Skin Tone", []string{"leg_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b5\U0001f3fc", "leg: Medium-Light Skin Tone", []string{"leg_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b5\U0001f3fd", "leg: Medium Skin Tone", []string{"leg_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f34b", "lemon", []string{"lemon"}, "6.0", false},
+ {"\u264c", "Leo", []string{"leo"}, "", false},
+ {"\U0001f406", "leopard", []string{"leopard"}, "6.0", false},
+ {"\U0001f1f1\U0001f1f8", "flag: Lesotho", []string{"lesotho"}, "6.0", false},
+ {"\U0001f39a\ufe0f", "level slider", []string{"level_slider"}, "7.0", false},
+ {"\U0001f1f1\U0001f1f7", "flag: Liberia", []string{"liberia"}, "6.0", false},
+ {"\u264e", "Libra", []string{"libra"}, "", false},
+ {"\U0001f1f1\U0001f1fe", "flag: Libya", []string{"libya"}, "6.0", false},
+ {"\U0001f1f1\U0001f1ee", "flag: Liechtenstein", []string{"liechtenstein"}, "6.0", false},
+ {"\U0001fa75", "light blue heart", []string{"light_blue_heart"}, "15.0", false},
+ {"\U0001f688", "light rail", []string{"light_rail"}, "6.0", false},
+ {"\U0001f517", "link", []string{"link"}, "6.0", false},
+ {"\U0001f981", "lion", []string{"lion"}, "8.0", false},
+ {"\U0001f444", "mouth", []string{"lips"}, "6.0", false},
+ {"\U0001f484", "lipstick", []string{"lipstick"}, "6.0", false},
+ {"\U0001f1f1\U0001f1f9", "flag: Lithuania", []string{"lithuania"}, "6.0", false},
+ {"\U0001f98e", "lizard", []string{"lizard"}, "9.0", false},
+ {"\U0001f999", "llama", []string{"llama"}, "11.0", false},
+ {"\U0001f99e", "lobster", []string{"lobster"}, "11.0", false},
+ {"\U0001f512", "locked", []string{"lock"}, "6.0", false},
+ {"\U0001f50f", "locked with pen", []string{"lock_with_ink_pen"}, "6.0", false},
+ {"\U0001f36d", "lollipop", []string{"lollipop"}, "6.0", false},
+ {"\U0001fa98", "long drum", []string{"long_drum"}, "13.0", false},
+ {"\u27bf", "double curly loop", []string{"loop"}, "6.0", false},
+ {"\U0001f9f4", "lotion bottle", []string{"lotion_bottle"}, "11.0", false},
+ {"\U0001fab7", "lotus", []string{"lotus"}, "14.0", false},
+ {"\U0001f9d8", "person in lotus position", []string{"lotus_position"}, "11.0", true},
+ {"\U0001f9d8\U0001f3ff", "person in lotus position: Dark Skin Tone", []string{"lotus_position_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fb", "person in lotus position: Light Skin Tone", []string{"lotus_position_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fe", "person in lotus position: Medium-Dark Skin Tone", []string{"lotus_position_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fc", "person in lotus position: Medium-Light Skin Tone", []string{"lotus_position_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fd", "person in lotus position: Medium Skin Tone", []string{"lotus_position_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\u200d\u2642\ufe0f", "man in lotus position", []string{"lotus_position_man"}, "11.0", true},
+ {"\U0001f9d8\U0001f3ff\u200d\u2642\ufe0f", "man in lotus position: Dark Skin Tone", []string{"lotus_position_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fb\u200d\u2642\ufe0f", "man in lotus position: Light Skin Tone", []string{"lotus_position_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fe\u200d\u2642\ufe0f", "man in lotus position: Medium-Dark Skin Tone", []string{"lotus_position_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fc\u200d\u2642\ufe0f", "man in lotus position: Medium-Light Skin Tone", []string{"lotus_position_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fd\u200d\u2642\ufe0f", "man in lotus position: Medium Skin Tone", []string{"lotus_position_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\u200d\u2640\ufe0f", "woman in lotus position", []string{"lotus_position_woman"}, "11.0", true},
+ {"\U0001f9d8\U0001f3ff\u200d\u2640\ufe0f", "woman in lotus position: Dark Skin Tone", []string{"lotus_position_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fb\u200d\u2640\ufe0f", "woman in lotus position: Light Skin Tone", []string{"lotus_position_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fe\u200d\u2640\ufe0f", "woman in lotus position: Medium-Dark Skin Tone", []string{"lotus_position_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fc\u200d\u2640\ufe0f", "woman in lotus position: Medium-Light Skin Tone", []string{"lotus_position_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d8\U0001f3fd\u200d\u2640\ufe0f", "woman in lotus position: Medium Skin Tone", []string{"lotus_position_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f50a", "speaker high volume", []string{"loud_sound"}, "6.0", false},
+ {"\U0001f4e2", "loudspeaker", []string{"loudspeaker"}, "6.0", false},
+ {"\U0001f3e9", "love hotel", []string{"love_hotel"}, "6.0", false},
+ {"\U0001f48c", "love letter", []string{"love_letter"}, "6.0", false},
+ {"\U0001f91f", "love-you gesture", []string{"love_you_gesture"}, "11.0", true},
+ {"\U0001f91f\U0001f3ff", "love-you gesture: Dark Skin Tone", []string{"love_you_gesture_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91f\U0001f3fb", "love-you gesture: Light Skin Tone", []string{"love_you_gesture_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91f\U0001f3fe", "love-you gesture: Medium-Dark Skin Tone", []string{"love_you_gesture_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91f\U0001f3fc", "love-you gesture: Medium-Light Skin Tone", []string{"love_you_gesture_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91f\U0001f3fd", "love-you gesture: Medium Skin Tone", []string{"love_you_gesture_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001faab", "low battery", []string{"low_battery"}, "14.0", false},
+ {"\U0001f505", "dim button", []string{"low_brightness"}, "6.0", false},
+ {"\U0001f9f3", "luggage", []string{"luggage"}, "11.0", false},
+ {"\U0001fac1", "lungs", []string{"lungs"}, "13.0", false},
+ {"\U0001f1f1\U0001f1fa", "flag: Luxembourg", []string{"luxembourg"}, "6.0", false},
+ {"\U0001f925", "lying face", []string{"lying_face"}, "9.0", false},
+ {"\u24c2\ufe0f", "circled M", []string{"m"}, "", false},
+ {"\U0001f1f2\U0001f1f4", "flag: Macao SAR China", []string{"macau"}, "6.0", false},
+ {"\U0001f1f2\U0001f1f0", "flag: North Macedonia", []string{"macedonia"}, "6.0", false},
+ {"\U0001f1f2\U0001f1ec", "flag: Madagascar", []string{"madagascar"}, "6.0", false},
+ {"\U0001f50d", "magnifying glass tilted left", []string{"mag"}, "6.0", false},
+ {"\U0001f50e", "magnifying glass tilted right", []string{"mag_right"}, "6.0", false},
+ {"\U0001f9d9", "mage", []string{"mage"}, "11.0", true},
+ {"\U0001f9d9\U0001f3ff", "mage: Dark Skin Tone", []string{"mage_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fb", "mage: Light Skin Tone", []string{"mage_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fe", "mage: Medium-Dark Skin Tone", []string{"mage_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fc", "mage: Medium-Light Skin Tone", []string{"mage_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fd", "mage: Medium Skin Tone", []string{"mage_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\u200d\u2642\ufe0f", "man mage", []string{"mage_man"}, "11.0", true},
+ {"\U0001f9d9\U0001f3ff\u200d\u2642\ufe0f", "man mage: Dark Skin Tone", []string{"mage_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fb\u200d\u2642\ufe0f", "man mage: Light Skin Tone", []string{"mage_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fe\u200d\u2642\ufe0f", "man mage: Medium-Dark Skin Tone", []string{"mage_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fc\u200d\u2642\ufe0f", "man mage: Medium-Light Skin Tone", []string{"mage_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fd\u200d\u2642\ufe0f", "man mage: Medium Skin Tone", []string{"mage_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\u200d\u2640\ufe0f", "woman mage", []string{"mage_woman"}, "11.0", true},
+ {"\U0001f9d9\U0001f3ff\u200d\u2640\ufe0f", "woman mage: Dark Skin Tone", []string{"mage_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fb\u200d\u2640\ufe0f", "woman mage: Light Skin Tone", []string{"mage_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fe\u200d\u2640\ufe0f", "woman mage: Medium-Dark Skin Tone", []string{"mage_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fc\u200d\u2640\ufe0f", "woman mage: Medium-Light Skin Tone", []string{"mage_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d9\U0001f3fd\u200d\u2640\ufe0f", "woman mage: Medium Skin Tone", []string{"mage_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fa84", "magic wand", []string{"magic_wand"}, "13.0", false},
+ {"\U0001f9f2", "magnet", []string{"magnet"}, "11.0", false},
+ {"\U0001f004", "mahjong red dragon", []string{"mahjong"}, "", false},
+ {"\U0001f4eb", "closed mailbox with raised flag", []string{"mailbox"}, "6.0", false},
+ {"\U0001f4ea", "closed mailbox with lowered flag", []string{"mailbox_closed"}, "6.0", false},
+ {"\U0001f4ec", "open mailbox with raised flag", []string{"mailbox_with_mail"}, "6.0", false},
+ {"\U0001f4ed", "open mailbox with lowered flag", []string{"mailbox_with_no_mail"}, "6.0", false},
+ {"\U0001f1f2\U0001f1fc", "flag: Malawi", []string{"malawi"}, "6.0", false},
+ {"\U0001f1f2\U0001f1fe", "flag: Malaysia", []string{"malaysia"}, "6.0", false},
+ {"\U0001f1f2\U0001f1fb", "flag: Maldives", []string{"maldives"}, "6.0", false},
+ {"\U0001f575\ufe0f\u200d\u2642\ufe0f", "man detective", []string{"male_detective"}, "11.0", true},
+ {"\U0001f575\U0001f3ff\ufe0f\u200d\u2642\ufe0f", "man detective: Dark Skin Tone", []string{"male_detective_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fb\ufe0f\u200d\u2642\ufe0f", "man detective: Light Skin Tone", []string{"male_detective_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fe\ufe0f\u200d\u2642\ufe0f", "man detective: Medium-Dark Skin Tone", []string{"male_detective_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fc\ufe0f\u200d\u2642\ufe0f", "man detective: Medium-Light Skin Tone", []string{"male_detective_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f575\U0001f3fd\ufe0f\u200d\u2642\ufe0f", "man detective: Medium Skin Tone", []string{"male_detective_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2642\ufe0f", "male sign", []string{"male_sign"}, "11.0", false},
+ {"\U0001f1f2\U0001f1f1", "flag: Mali", []string{"mali"}, "6.0", false},
+ {"\U0001f1f2\U0001f1f9", "flag: Malta", []string{"malta"}, "6.0", false},
+ {"\U0001f9a3", "mammoth", []string{"mammoth"}, "13.0", false},
+ {"\U0001f468", "man", []string{"man"}, "6.0", true},
+ {"\U0001f468\U0001f3ff", "man: Dark Skin Tone", []string{"man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb", "man: Light Skin Tone", []string{"man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe", "man: Medium-Dark Skin Tone", []string{"man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc", "man: Medium-Light Skin Tone", []string{"man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd", "man: Medium Skin Tone", []string{"man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f3a8", "man artist", []string{"man_artist"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f3a8", "man artist: Dark Skin Tone", []string{"man_artist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f3a8", "man artist: Light Skin Tone", []string{"man_artist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f3a8", "man artist: Medium-Dark Skin Tone", []string{"man_artist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f3a8", "man artist: Medium-Light Skin Tone", []string{"man_artist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f3a8", "man artist: Medium Skin Tone", []string{"man_artist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f680", "man astronaut", []string{"man_astronaut"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f680", "man astronaut: Dark Skin Tone", []string{"man_astronaut_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f680", "man astronaut: Light Skin Tone", []string{"man_astronaut_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f680", "man astronaut: Medium-Dark Skin Tone", []string{"man_astronaut_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f680", "man astronaut: Medium-Light Skin Tone", []string{"man_astronaut_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f680", "man astronaut: Medium Skin Tone", []string{"man_astronaut_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\u200d\u2642\ufe0f", "man: beard", []string{"man_beard"}, "13.1", true},
+ {"\U0001f9d4\U0001f3ff\u200d\u2642\ufe0f", "man: beard: Dark Skin Tone", []string{"man_beard_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fb\u200d\u2642\ufe0f", "man: beard: Light Skin Tone", []string{"man_beard_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fe\u200d\u2642\ufe0f", "man: beard: Medium-Dark Skin Tone", []string{"man_beard_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fc\u200d\u2642\ufe0f", "man: beard: Medium-Light Skin Tone", []string{"man_beard_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fd\u200d\u2642\ufe0f", "man: beard: Medium Skin Tone", []string{"man_beard_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\u200d\u2642\ufe0f", "man cartwheeling", []string{"man_cartwheeling"}, "", true},
+ {"\U0001f938\U0001f3ff\u200d\u2642\ufe0f", "man cartwheeling: Dark Skin Tone", []string{"man_cartwheeling_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fb\u200d\u2642\ufe0f", "man cartwheeling: Light Skin Tone", []string{"man_cartwheeling_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fe\u200d\u2642\ufe0f", "man cartwheeling: Medium-Dark Skin Tone", []string{"man_cartwheeling_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fc\u200d\u2642\ufe0f", "man cartwheeling: Medium-Light Skin Tone", []string{"man_cartwheeling_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fd\u200d\u2642\ufe0f", "man cartwheeling: Medium Skin Tone", []string{"man_cartwheeling_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f373", "man cook", []string{"man_cook"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f373", "man cook: Dark Skin Tone", []string{"man_cook_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f373", "man cook: Light Skin Tone", []string{"man_cook_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f373", "man cook: Medium-Dark Skin Tone", []string{"man_cook_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f373", "man cook: Medium-Light Skin Tone", []string{"man_cook_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f373", "man cook: Medium Skin Tone", []string{"man_cook_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f57a", "man dancing", []string{"man_dancing"}, "9.0", true},
+ {"\U0001f57a\U0001f3ff", "man dancing: Dark Skin Tone", []string{"man_dancing_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f57a\U0001f3fb", "man dancing: Light Skin Tone", []string{"man_dancing_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f57a\U0001f3fe", "man dancing: Medium-Dark Skin Tone", []string{"man_dancing_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f57a\U0001f3fc", "man dancing: Medium-Light Skin Tone", []string{"man_dancing_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f57a\U0001f3fd", "man dancing: Medium Skin Tone", []string{"man_dancing_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\u200d\u2642\ufe0f", "man facepalming", []string{"man_facepalming"}, "9.0", true},
+ {"\U0001f926\U0001f3ff\u200d\u2642\ufe0f", "man facepalming: Dark Skin Tone", []string{"man_facepalming_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fb\u200d\u2642\ufe0f", "man facepalming: Light Skin Tone", []string{"man_facepalming_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fe\u200d\u2642\ufe0f", "man facepalming: Medium-Dark Skin Tone", []string{"man_facepalming_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fc\u200d\u2642\ufe0f", "man facepalming: Medium-Light Skin Tone", []string{"man_facepalming_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fd\u200d\u2642\ufe0f", "man facepalming: Medium Skin Tone", []string{"man_facepalming_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f3ed", "man factory worker", []string{"man_factory_worker"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f3ed", "man factory worker: Dark Skin Tone", []string{"man_factory_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f3ed", "man factory worker: Light Skin Tone", []string{"man_factory_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f3ed", "man factory worker: Medium-Dark Skin Tone", []string{"man_factory_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f3ed", "man factory worker: Medium-Light Skin Tone", []string{"man_factory_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f3ed", "man factory worker: Medium Skin Tone", []string{"man_factory_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f33e", "man farmer", []string{"man_farmer"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f33e", "man farmer: Dark Skin Tone", []string{"man_farmer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f33e", "man farmer: Light Skin Tone", []string{"man_farmer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f33e", "man farmer: Medium-Dark Skin Tone", []string{"man_farmer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f33e", "man farmer: Medium-Light Skin Tone", []string{"man_farmer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f33e", "man farmer: Medium Skin Tone", []string{"man_farmer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f37c", "man feeding baby", []string{"man_feeding_baby"}, "13.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f37c", "man feeding baby: Dark Skin Tone", []string{"man_feeding_baby_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f37c", "man feeding baby: Light Skin Tone", []string{"man_feeding_baby_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f37c", "man feeding baby: Medium-Dark Skin Tone", []string{"man_feeding_baby_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f37c", "man feeding baby: Medium-Light Skin Tone", []string{"man_feeding_baby_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f37c", "man feeding baby: Medium Skin Tone", []string{"man_feeding_baby_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f692", "man firefighter", []string{"man_firefighter"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f692", "man firefighter: Dark Skin Tone", []string{"man_firefighter_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f692", "man firefighter: Light Skin Tone", []string{"man_firefighter_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f692", "man firefighter: Medium-Dark Skin Tone", []string{"man_firefighter_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f692", "man firefighter: Medium-Light Skin Tone", []string{"man_firefighter_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f692", "man firefighter: Medium Skin Tone", []string{"man_firefighter_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\u2695\ufe0f", "man health worker", []string{"man_health_worker"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\u2695\ufe0f", "man health worker: Dark Skin Tone", []string{"man_health_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\u2695\ufe0f", "man health worker: Light Skin Tone", []string{"man_health_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\u2695\ufe0f", "man health worker: Medium-Dark Skin Tone", []string{"man_health_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\u2695\ufe0f", "man health worker: Medium-Light Skin Tone", []string{"man_health_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\u2695\ufe0f", "man health worker: Medium Skin Tone", []string{"man_health_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f9bd", "man in manual wheelchair", []string{"man_in_manual_wheelchair"}, "12.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9bd", "man in manual wheelchair: Dark Skin Tone", []string{"man_in_manual_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9bd", "man in manual wheelchair: Light Skin Tone", []string{"man_in_manual_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9bd", "man in manual wheelchair: Medium-Dark Skin Tone", []string{"man_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9bd", "man in manual wheelchair: Medium-Light Skin Tone", []string{"man_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9bd", "man in manual wheelchair: Medium Skin Tone", []string{"man_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f9bc", "man in motorized wheelchair", []string{"man_in_motorized_wheelchair"}, "12.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9bc", "man in motorized wheelchair: Dark Skin Tone", []string{"man_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9bc", "man in motorized wheelchair: Light Skin Tone", []string{"man_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Dark Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Light Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9bc", "man in motorized wheelchair: Medium Skin Tone", []string{"man_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\u200d\u2642\ufe0f", "man in tuxedo", []string{"man_in_tuxedo"}, "13.0", true},
+ {"\U0001f935\U0001f3ff\u200d\u2642\ufe0f", "man in tuxedo: Dark Skin Tone", []string{"man_in_tuxedo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fb\u200d\u2642\ufe0f", "man in tuxedo: Light Skin Tone", []string{"man_in_tuxedo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fe\u200d\u2642\ufe0f", "man in tuxedo: Medium-Dark Skin Tone", []string{"man_in_tuxedo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fc\u200d\u2642\ufe0f", "man in tuxedo: Medium-Light Skin Tone", []string{"man_in_tuxedo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fd\u200d\u2642\ufe0f", "man in tuxedo: Medium Skin Tone", []string{"man_in_tuxedo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\u2696\ufe0f", "man judge", []string{"man_judge"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\u2696\ufe0f", "man judge: Dark Skin Tone", []string{"man_judge_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\u2696\ufe0f", "man judge: Light Skin Tone", []string{"man_judge_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\u2696\ufe0f", "man judge: Medium-Dark Skin Tone", []string{"man_judge_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\u2696\ufe0f", "man judge: Medium-Light Skin Tone", []string{"man_judge_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\u2696\ufe0f", "man judge: Medium Skin Tone", []string{"man_judge_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\u200d\u2642\ufe0f", "man juggling", []string{"man_juggling"}, "9.0", true},
+ {"\U0001f939\U0001f3ff\u200d\u2642\ufe0f", "man juggling: Dark Skin Tone", []string{"man_juggling_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fb\u200d\u2642\ufe0f", "man juggling: Light Skin Tone", []string{"man_juggling_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fe\u200d\u2642\ufe0f", "man juggling: Medium-Dark Skin Tone", []string{"man_juggling_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fc\u200d\u2642\ufe0f", "man juggling: Medium-Light Skin Tone", []string{"man_juggling_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fd\u200d\u2642\ufe0f", "man juggling: Medium Skin Tone", []string{"man_juggling_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f527", "man mechanic", []string{"man_mechanic"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f527", "man mechanic: Dark Skin Tone", []string{"man_mechanic_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f527", "man mechanic: Light Skin Tone", []string{"man_mechanic_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f527", "man mechanic: Medium-Dark Skin Tone", []string{"man_mechanic_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f527", "man mechanic: Medium-Light Skin Tone", []string{"man_mechanic_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f527", "man mechanic: Medium Skin Tone", []string{"man_mechanic_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f4bc", "man office worker", []string{"man_office_worker"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f4bc", "man office worker: Dark Skin Tone", []string{"man_office_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f4bc", "man office worker: Light Skin Tone", []string{"man_office_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f4bc", "man office worker: Medium-Dark Skin Tone", []string{"man_office_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f4bc", "man office worker: Medium-Light Skin Tone", []string{"man_office_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f4bc", "man office worker: Medium Skin Tone", []string{"man_office_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\u2708\ufe0f", "man pilot", []string{"man_pilot"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\u2708\ufe0f", "man pilot: Dark Skin Tone", []string{"man_pilot_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\u2708\ufe0f", "man pilot: Light Skin Tone", []string{"man_pilot_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\u2708\ufe0f", "man pilot: Medium-Dark Skin Tone", []string{"man_pilot_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\u2708\ufe0f", "man pilot: Medium-Light Skin Tone", []string{"man_pilot_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\u2708\ufe0f", "man pilot: Medium Skin Tone", []string{"man_pilot_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\u200d\u2642\ufe0f", "man playing handball", []string{"man_playing_handball"}, "9.0", true},
+ {"\U0001f93e\U0001f3ff\u200d\u2642\ufe0f", "man playing handball: Dark Skin Tone", []string{"man_playing_handball_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fb\u200d\u2642\ufe0f", "man playing handball: Light Skin Tone", []string{"man_playing_handball_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fe\u200d\u2642\ufe0f", "man playing handball: Medium-Dark Skin Tone", []string{"man_playing_handball_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fc\u200d\u2642\ufe0f", "man playing handball: Medium-Light Skin Tone", []string{"man_playing_handball_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fd\u200d\u2642\ufe0f", "man playing handball: Medium Skin Tone", []string{"man_playing_handball_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\u200d\u2642\ufe0f", "man playing water polo", []string{"man_playing_water_polo"}, "9.0", true},
+ {"\U0001f93d\U0001f3ff\u200d\u2642\ufe0f", "man playing water polo: Dark Skin Tone", []string{"man_playing_water_polo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fb\u200d\u2642\ufe0f", "man playing water polo: Light Skin Tone", []string{"man_playing_water_polo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fe\u200d\u2642\ufe0f", "man playing water polo: Medium-Dark Skin Tone", []string{"man_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fc\u200d\u2642\ufe0f", "man playing water polo: Medium-Light Skin Tone", []string{"man_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fd\u200d\u2642\ufe0f", "man playing water polo: Medium Skin Tone", []string{"man_playing_water_polo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f52c", "man scientist", []string{"man_scientist"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f52c", "man scientist: Dark Skin Tone", []string{"man_scientist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f52c", "man scientist: Light Skin Tone", []string{"man_scientist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f52c", "man scientist: Medium-Dark Skin Tone", []string{"man_scientist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f52c", "man scientist: Medium-Light Skin Tone", []string{"man_scientist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f52c", "man scientist: Medium Skin Tone", []string{"man_scientist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\u200d\u2642\ufe0f", "man shrugging", []string{"man_shrugging"}, "9.0", true},
+ {"\U0001f937\U0001f3ff\u200d\u2642\ufe0f", "man shrugging: Dark Skin Tone", []string{"man_shrugging_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fb\u200d\u2642\ufe0f", "man shrugging: Light Skin Tone", []string{"man_shrugging_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fe\u200d\u2642\ufe0f", "man shrugging: Medium-Dark Skin Tone", []string{"man_shrugging_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fc\u200d\u2642\ufe0f", "man shrugging: Medium-Light Skin Tone", []string{"man_shrugging_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fd\u200d\u2642\ufe0f", "man shrugging: Medium Skin Tone", []string{"man_shrugging_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f3a4", "man singer", []string{"man_singer"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f3a4", "man singer: Dark Skin Tone", []string{"man_singer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f3a4", "man singer: Light Skin Tone", []string{"man_singer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f3a4", "man singer: Medium-Dark Skin Tone", []string{"man_singer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f3a4", "man singer: Medium-Light Skin Tone", []string{"man_singer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f3a4", "man singer: Medium Skin Tone", []string{"man_singer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f393", "man student", []string{"man_student"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f393", "man student: Dark Skin Tone", []string{"man_student_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f393", "man student: Light Skin Tone", []string{"man_student_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f393", "man student: Medium-Dark Skin Tone", []string{"man_student_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f393", "man student: Medium-Light Skin Tone", []string{"man_student_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f393", "man student: Medium Skin Tone", []string{"man_student_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f3eb", "man teacher", []string{"man_teacher"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f3eb", "man teacher: Dark Skin Tone", []string{"man_teacher_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f3eb", "man teacher: Light Skin Tone", []string{"man_teacher_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f3eb", "man teacher: Medium-Dark Skin Tone", []string{"man_teacher_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f3eb", "man teacher: Medium-Light Skin Tone", []string{"man_teacher_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f3eb", "man teacher: Medium Skin Tone", []string{"man_teacher_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f4bb", "man technologist", []string{"man_technologist"}, "", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f4bb", "man technologist: Dark Skin Tone", []string{"man_technologist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f4bb", "man technologist: Light Skin Tone", []string{"man_technologist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f4bb", "man technologist: Medium-Dark Skin Tone", []string{"man_technologist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f4bb", "man technologist: Medium-Light Skin Tone", []string{"man_technologist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f4bb", "man technologist: Medium Skin Tone", []string{"man_technologist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f472", "person with skullcap", []string{"man_with_gua_pi_mao"}, "6.0", true},
+ {"\U0001f472\U0001f3ff", "person with skullcap: Dark Skin Tone", []string{"man_with_gua_pi_mao_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f472\U0001f3fb", "person with skullcap: Light Skin Tone", []string{"man_with_gua_pi_mao_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f472\U0001f3fe", "person with skullcap: Medium-Dark Skin Tone", []string{"man_with_gua_pi_mao_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f472\U0001f3fc", "person with skullcap: Medium-Light Skin Tone", []string{"man_with_gua_pi_mao_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f472\U0001f3fd", "person with skullcap: Medium Skin Tone", []string{"man_with_gua_pi_mao_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\u200d\U0001f9af", "man with white cane", []string{"man_with_probing_cane"}, "12.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9af", "man with white cane: Dark Skin Tone", []string{"man_with_probing_cane_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9af", "man with white cane: Light Skin Tone", []string{"man_with_probing_cane_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9af", "man with white cane: Medium-Dark Skin Tone", []string{"man_with_probing_cane_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9af", "man with white cane: Medium-Light Skin Tone", []string{"man_with_probing_cane_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9af", "man with white cane: Medium Skin Tone", []string{"man_with_probing_cane_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\u200d\u2642\ufe0f", "man wearing turban", []string{"man_with_turban"}, "11.0", true},
+ {"\U0001f473\U0001f3ff\u200d\u2642\ufe0f", "man wearing turban: Dark Skin Tone", []string{"man_with_turban_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fb\u200d\u2642\ufe0f", "man wearing turban: Light Skin Tone", []string{"man_with_turban_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fe\u200d\u2642\ufe0f", "man wearing turban: Medium-Dark Skin Tone", []string{"man_with_turban_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fc\u200d\u2642\ufe0f", "man wearing turban: Medium-Light Skin Tone", []string{"man_with_turban_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fd\u200d\u2642\ufe0f", "man wearing turban: Medium Skin Tone", []string{"man_with_turban_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\u200d\u2642\ufe0f", "man with veil", []string{"man_with_veil"}, "13.0", true},
+ {"\U0001f470\U0001f3ff\u200d\u2642\ufe0f", "man with veil: Dark Skin Tone", []string{"man_with_veil_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fb\u200d\u2642\ufe0f", "man with veil: Light Skin Tone", []string{"man_with_veil_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fe\u200d\u2642\ufe0f", "man with veil: Medium-Dark Skin Tone", []string{"man_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fc\u200d\u2642\ufe0f", "man with veil: Medium-Light Skin Tone", []string{"man_with_veil_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fd\u200d\u2642\ufe0f", "man with veil: Medium Skin Tone", []string{"man_with_veil_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f96d", "mango", []string{"mango"}, "11.0", false},
+ {"\U0001f45e", "man’s shoe", []string{"mans_shoe", "shoe"}, "6.0", false},
+ {"\U0001f570\ufe0f", "mantelpiece clock", []string{"mantelpiece_clock"}, "7.0", false},
+ {"\U0001f9bd", "manual wheelchair", []string{"manual_wheelchair"}, "12.0", false},
+ {"\U0001f341", "maple leaf", []string{"maple_leaf"}, "6.0", false},
+ {"\U0001fa87", "maracas", []string{"maracas"}, "15.0", false},
+ {"\U0001f1f2\U0001f1ed", "flag: Marshall Islands", []string{"marshall_islands"}, "6.0", false},
+ {"\U0001f94b", "martial arts uniform", []string{"martial_arts_uniform"}, "9.0", false},
+ {"\U0001f1f2\U0001f1f6", "flag: Martinique", []string{"martinique"}, "6.0", false},
+ {"\U0001f637", "face with medical mask", []string{"mask"}, "6.0", false},
+ {"\U0001f486", "person getting massage", []string{"massage"}, "6.0", true},
+ {"\U0001f486\U0001f3ff", "person getting massage: Dark Skin Tone", []string{"massage_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fb", "person getting massage: Light Skin Tone", []string{"massage_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fe", "person getting massage: Medium-Dark Skin Tone", []string{"massage_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fc", "person getting massage: Medium-Light Skin Tone", []string{"massage_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fd", "person getting massage: Medium Skin Tone", []string{"massage_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\u200d\u2642\ufe0f", "man getting massage", []string{"massage_man"}, "6.0", true},
+ {"\U0001f486\U0001f3ff\u200d\u2642\ufe0f", "man getting massage: Dark Skin Tone", []string{"massage_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fb\u200d\u2642\ufe0f", "man getting massage: Light Skin Tone", []string{"massage_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fe\u200d\u2642\ufe0f", "man getting massage: Medium-Dark Skin Tone", []string{"massage_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fc\u200d\u2642\ufe0f", "man getting massage: Medium-Light Skin Tone", []string{"massage_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fd\u200d\u2642\ufe0f", "man getting massage: Medium Skin Tone", []string{"massage_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\u200d\u2640\ufe0f", "woman getting massage", []string{"massage_woman"}, "11.0", true},
+ {"\U0001f486\U0001f3ff\u200d\u2640\ufe0f", "woman getting massage: Dark Skin Tone", []string{"massage_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fb\u200d\u2640\ufe0f", "woman getting massage: Light Skin Tone", []string{"massage_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fe\u200d\u2640\ufe0f", "woman getting massage: Medium-Dark Skin Tone", []string{"massage_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fc\u200d\u2640\ufe0f", "woman getting massage: Medium-Light Skin Tone", []string{"massage_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f486\U0001f3fd\u200d\u2640\ufe0f", "woman getting massage: Medium Skin Tone", []string{"massage_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9c9", "mate", []string{"mate"}, "12.0", false},
+ {"\U0001f1f2\U0001f1f7", "flag: Mauritania", []string{"mauritania"}, "6.0", false},
+ {"\U0001f1f2\U0001f1fa", "flag: Mauritius", []string{"mauritius"}, "6.0", false},
+ {"\U0001f1fe\U0001f1f9", "flag: Mayotte", []string{"mayotte"}, "6.0", false},
+ {"\U0001f356", "meat on bone", []string{"meat_on_bone"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f527", "mechanic", []string{"mechanic"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f527", "mechanic: Dark Skin Tone", []string{"mechanic_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f527", "mechanic: Light Skin Tone", []string{"mechanic_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f527", "mechanic: Medium-Dark Skin Tone", []string{"mechanic_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f527", "mechanic: Medium-Light Skin Tone", []string{"mechanic_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f527", "mechanic: Medium Skin Tone", []string{"mechanic_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9be", "mechanical arm", []string{"mechanical_arm"}, "12.0", false},
+ {"\U0001f9bf", "mechanical leg", []string{"mechanical_leg"}, "12.0", false},
+ {"\U0001f396\ufe0f", "military medal", []string{"medal_military"}, "7.0", false},
+ {"\U0001f3c5", "sports medal", []string{"medal_sports"}, "7.0", false},
+ {"\u2695\ufe0f", "medical symbol", []string{"medical_symbol"}, "11.0", false},
+ {"\U0001f4e3", "megaphone", []string{"mega"}, "6.0", false},
+ {"\U0001f348", "melon", []string{"melon"}, "6.0", false},
+ {"\U0001fae0", "melting face", []string{"melting_face"}, "14.0", false},
+ {"\U0001f4dd", "memo", []string{"memo", "pencil"}, "6.0", false},
+ {"\U0001f93c\u200d\u2642\ufe0f", "men wrestling", []string{"men_wrestling"}, "9.0", false},
+ {"\u2764\ufe0f\u200d\U0001fa79", "mending heart", []string{"mending_heart"}, "13.1", false},
+ {"\U0001f54e", "menorah", []string{"menorah"}, "8.0", false},
+ {"\U0001f6b9", "men’s room", []string{"mens"}, "6.0", false},
+ {"\U0001f9dc\u200d\u2640\ufe0f", "mermaid", []string{"mermaid"}, "11.0", true},
+ {"\U0001f9dc\U0001f3ff\u200d\u2640\ufe0f", "mermaid: Dark Skin Tone", []string{"mermaid_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fb\u200d\u2640\ufe0f", "mermaid: Light Skin Tone", []string{"mermaid_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fe\u200d\u2640\ufe0f", "mermaid: Medium-Dark Skin Tone", []string{"mermaid_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fc\u200d\u2640\ufe0f", "mermaid: Medium-Light Skin Tone", []string{"mermaid_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fd\u200d\u2640\ufe0f", "mermaid: Medium Skin Tone", []string{"mermaid_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\u200d\u2642\ufe0f", "merman", []string{"merman"}, "11.0", true},
+ {"\U0001f9dc\U0001f3ff\u200d\u2642\ufe0f", "merman: Dark Skin Tone", []string{"merman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fb\u200d\u2642\ufe0f", "merman: Light Skin Tone", []string{"merman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fe\u200d\u2642\ufe0f", "merman: Medium-Dark Skin Tone", []string{"merman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fc\u200d\u2642\ufe0f", "merman: Medium-Light Skin Tone", []string{"merman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fd\u200d\u2642\ufe0f", "merman: Medium Skin Tone", []string{"merman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc", "merperson", []string{"merperson"}, "11.0", true},
+ {"\U0001f9dc\U0001f3ff", "merperson: Dark Skin Tone", []string{"merperson_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fb", "merperson: Light Skin Tone", []string{"merperson_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fe", "merperson: Medium-Dark Skin Tone", []string{"merperson_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fc", "merperson: Medium-Light Skin Tone", []string{"merperson_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9dc\U0001f3fd", "merperson: Medium Skin Tone", []string{"merperson_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f918", "sign of the horns", []string{"metal"}, "8.0", true},
+ {"\U0001f918\U0001f3ff", "sign of the horns: Dark Skin Tone", []string{"metal_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f918\U0001f3fb", "sign of the horns: Light Skin Tone", []string{"metal_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f918\U0001f3fe", "sign of the horns: Medium-Dark Skin Tone", []string{"metal_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f918\U0001f3fc", "sign of the horns: Medium-Light Skin Tone", []string{"metal_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f918\U0001f3fd", "sign of the horns: Medium Skin Tone", []string{"metal_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f687", "metro", []string{"metro"}, "6.0", false},
+ {"\U0001f1f2\U0001f1fd", "flag: Mexico", []string{"mexico"}, "6.0", false},
+ {"\U0001f9a0", "microbe", []string{"microbe"}, "11.0", false},
+ {"\U0001f1eb\U0001f1f2", "flag: Micronesia", []string{"micronesia"}, "6.0", false},
+ {"\U0001f3a4", "microphone", []string{"microphone"}, "6.0", false},
+ {"\U0001f52c", "microscope", []string{"microscope"}, "6.0", false},
+ {"\U0001f595", "middle finger", []string{"middle_finger", "fu"}, "7.0", true},
+ {"\U0001f595\U0001f3ff", "middle finger: Dark Skin Tone", []string{"middle_finger_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f595\U0001f3fb", "middle finger: Light Skin Tone", []string{"middle_finger_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f595\U0001f3fe", "middle finger: Medium-Dark Skin Tone", []string{"middle_finger_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f595\U0001f3fc", "middle finger: Medium-Light Skin Tone", []string{"middle_finger_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f595\U0001f3fd", "middle finger: Medium Skin Tone", []string{"middle_finger_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fa96", "military helmet", []string{"military_helmet"}, "13.0", false},
+ {"\U0001f95b", "glass of milk", []string{"milk_glass"}, "9.0", false},
+ {"\U0001f30c", "milky way", []string{"milky_way"}, "6.0", false},
+ {"\U0001f690", "minibus", []string{"minibus"}, "6.0", false},
+ {"\U0001f4bd", "computer disk", []string{"minidisc"}, "6.0", false},
+ {"\U0001fa9e", "mirror", []string{"mirror"}, "13.0", false},
+ {"\U0001faa9", "mirror ball", []string{"mirror_ball"}, "14.0", false},
+ {"\U0001f4f4", "mobile phone off", []string{"mobile_phone_off"}, "6.0", false},
+ {"\U0001f1f2\U0001f1e9", "flag: Moldova", []string{"moldova"}, "6.0", false},
+ {"\U0001f1f2\U0001f1e8", "flag: Monaco", []string{"monaco"}, "6.0", false},
+ {"\U0001f911", "money-mouth face", []string{"money_mouth_face"}, "8.0", false},
+ {"\U0001f4b8", "money with wings", []string{"money_with_wings"}, "6.0", false},
+ {"\U0001f4b0", "money bag", []string{"moneybag"}, "6.0", false},
+ {"\U0001f1f2\U0001f1f3", "flag: Mongolia", []string{"mongolia"}, "6.0", false},
+ {"\U0001f412", "monkey", []string{"monkey"}, "6.0", false},
+ {"\U0001f435", "monkey face", []string{"monkey_face"}, "6.0", false},
+ {"\U0001f9d0", "face with monocle", []string{"monocle_face"}, "11.0", false},
+ {"\U0001f69d", "monorail", []string{"monorail"}, "6.0", false},
+ {"\U0001f1f2\U0001f1ea", "flag: Montenegro", []string{"montenegro"}, "6.0", false},
+ {"\U0001f1f2\U0001f1f8", "flag: Montserrat", []string{"montserrat"}, "6.0", false},
+ {"\U0001f314", "waxing gibbous moon", []string{"moon", "waxing_gibbous_moon"}, "6.0", false},
+ {"\U0001f96e", "moon cake", []string{"moon_cake"}, "11.0", false},
+ {"\U0001face", "moose", []string{"moose"}, "15.0", false},
+ {"\U0001f1f2\U0001f1e6", "flag: Morocco", []string{"morocco"}, "6.0", false},
+ {"\U0001f393", "graduation cap", []string{"mortar_board"}, "6.0", false},
+ {"\U0001f54c", "mosque", []string{"mosque"}, "8.0", false},
+ {"\U0001f99f", "mosquito", []string{"mosquito"}, "11.0", false},
+ {"\U0001f6e5\ufe0f", "motor boat", []string{"motor_boat"}, "7.0", false},
+ {"\U0001f6f5", "motor scooter", []string{"motor_scooter"}, "9.0", false},
+ {"\U0001f3cd\ufe0f", "motorcycle", []string{"motorcycle"}, "7.0", false},
+ {"\U0001f9bc", "motorized wheelchair", []string{"motorized_wheelchair"}, "12.0", false},
+ {"\U0001f6e3\ufe0f", "motorway", []string{"motorway"}, "7.0", false},
+ {"\U0001f5fb", "mount fuji", []string{"mount_fuji"}, "6.0", false},
+ {"\u26f0\ufe0f", "mountain", []string{"mountain"}, "5.2", false},
+ {"\U0001f6b5", "person mountain biking", []string{"mountain_bicyclist"}, "6.0", true},
+ {"\U0001f6b5\U0001f3ff", "person mountain biking: Dark Skin Tone", []string{"mountain_bicyclist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fb", "person mountain biking: Light Skin Tone", []string{"mountain_bicyclist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fe", "person mountain biking: Medium-Dark Skin Tone", []string{"mountain_bicyclist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fc", "person mountain biking: Medium-Light Skin Tone", []string{"mountain_bicyclist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fd", "person mountain biking: Medium Skin Tone", []string{"mountain_bicyclist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\u200d\u2642\ufe0f", "man mountain biking", []string{"mountain_biking_man"}, "11.0", true},
+ {"\U0001f6b5\U0001f3ff\u200d\u2642\ufe0f", "man mountain biking: Dark Skin Tone", []string{"mountain_biking_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fb\u200d\u2642\ufe0f", "man mountain biking: Light Skin Tone", []string{"mountain_biking_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fe\u200d\u2642\ufe0f", "man mountain biking: Medium-Dark Skin Tone", []string{"mountain_biking_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fc\u200d\u2642\ufe0f", "man mountain biking: Medium-Light Skin Tone", []string{"mountain_biking_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fd\u200d\u2642\ufe0f", "man mountain biking: Medium Skin Tone", []string{"mountain_biking_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\u200d\u2640\ufe0f", "woman mountain biking", []string{"mountain_biking_woman"}, "6.0", true},
+ {"\U0001f6b5\U0001f3ff\u200d\u2640\ufe0f", "woman mountain biking: Dark Skin Tone", []string{"mountain_biking_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fb\u200d\u2640\ufe0f", "woman mountain biking: Light Skin Tone", []string{"mountain_biking_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fe\u200d\u2640\ufe0f", "woman mountain biking: Medium-Dark Skin Tone", []string{"mountain_biking_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fc\u200d\u2640\ufe0f", "woman mountain biking: Medium-Light Skin Tone", []string{"mountain_biking_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b5\U0001f3fd\u200d\u2640\ufe0f", "woman mountain biking: Medium Skin Tone", []string{"mountain_biking_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a0", "mountain cableway", []string{"mountain_cableway"}, "6.0", false},
+ {"\U0001f69e", "mountain railway", []string{"mountain_railway"}, "6.0", false},
+ {"\U0001f3d4\ufe0f", "snow-capped mountain", []string{"mountain_snow"}, "7.0", false},
+ {"\U0001f42d", "mouse face", []string{"mouse"}, "6.0", false},
+ {"\U0001f401", "mouse", []string{"mouse2"}, "6.0", false},
+ {"\U0001faa4", "mouse trap", []string{"mouse_trap"}, "13.0", false},
+ {"\U0001f3a5", "movie camera", []string{"movie_camera"}, "6.0", false},
+ {"\U0001f5ff", "moai", []string{"moyai"}, "6.0", false},
+ {"\U0001f1f2\U0001f1ff", "flag: Mozambique", []string{"mozambique"}, "6.0", false},
+ {"\U0001f936", "Mrs. Claus", []string{"mrs_claus"}, "9.0", true},
+ {"\U0001f936\U0001f3ff", "Mrs. Claus: Dark Skin Tone", []string{"mrs_claus_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f936\U0001f3fb", "Mrs. Claus: Light Skin Tone", []string{"mrs_claus_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f936\U0001f3fe", "Mrs. Claus: Medium-Dark Skin Tone", []string{"mrs_claus_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f936\U0001f3fc", "Mrs. Claus: Medium-Light Skin Tone", []string{"mrs_claus_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f936\U0001f3fd", "Mrs. Claus: Medium Skin Tone", []string{"mrs_claus_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4aa", "flexed biceps", []string{"muscle"}, "6.0", true},
+ {"\U0001f4aa\U0001f3ff", "flexed biceps: Dark Skin Tone", []string{"muscle_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f4aa\U0001f3fb", "flexed biceps: Light Skin Tone", []string{"muscle_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f4aa\U0001f3fe", "flexed biceps: Medium-Dark Skin Tone", []string{"muscle_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f4aa\U0001f3fc", "flexed biceps: Medium-Light Skin Tone", []string{"muscle_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f4aa\U0001f3fd", "flexed biceps: Medium Skin Tone", []string{"muscle_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f344", "mushroom", []string{"mushroom"}, "6.0", false},
+ {"\U0001f3b9", "musical keyboard", []string{"musical_keyboard"}, "6.0", false},
+ {"\U0001f3b5", "musical note", []string{"musical_note"}, "6.0", false},
+ {"\U0001f3bc", "musical score", []string{"musical_score"}, "6.0", false},
+ {"\U0001f507", "muted speaker", []string{"mute"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f384", "mx claus", []string{"mx_claus"}, "13.0", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f384", "mx claus: Dark Skin Tone", []string{"mx_claus_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f384", "mx claus: Light Skin Tone", []string{"mx_claus_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f384", "mx claus: Medium-Dark Skin Tone", []string{"mx_claus_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f384", "mx claus: Medium-Light Skin Tone", []string{"mx_claus_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f384", "mx claus: Medium Skin Tone", []string{"mx_claus_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f2\U0001f1f2", "flag: Myanmar (Burma)", []string{"myanmar"}, "6.0", false},
+ {"\U0001f485", "nail polish", []string{"nail_care"}, "6.0", true},
+ {"\U0001f485\U0001f3ff", "nail polish: Dark Skin Tone", []string{"nail_care_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f485\U0001f3fb", "nail polish: Light Skin Tone", []string{"nail_care_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f485\U0001f3fe", "nail polish: Medium-Dark Skin Tone", []string{"nail_care_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f485\U0001f3fc", "nail polish: Medium-Light Skin Tone", []string{"nail_care_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f485\U0001f3fd", "nail polish: Medium Skin Tone", []string{"nail_care_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4db", "name badge", []string{"name_badge"}, "6.0", false},
+ {"\U0001f1f3\U0001f1e6", "flag: Namibia", []string{"namibia"}, "6.0", false},
+ {"\U0001f3de\ufe0f", "national park", []string{"national_park"}, "7.0", false},
+ {"\U0001f1f3\U0001f1f7", "flag: Nauru", []string{"nauru"}, "6.0", false},
+ {"\U0001f922", "nauseated face", []string{"nauseated_face"}, "9.0", false},
+ {"\U0001f9ff", "nazar amulet", []string{"nazar_amulet"}, "11.0", false},
+ {"\U0001f454", "necktie", []string{"necktie"}, "6.0", false},
+ {"\u274e", "cross mark button", []string{"negative_squared_cross_mark"}, "6.0", false},
+ {"\U0001f1f3\U0001f1f5", "flag: Nepal", []string{"nepal"}, "6.0", false},
+ {"\U0001f913", "nerd face", []string{"nerd_face"}, "8.0", false},
+ {"\U0001faba", "nest with eggs", []string{"nest_with_eggs"}, "14.0", false},
+ {"\U0001fa86", "nesting dolls", []string{"nesting_dolls"}, "13.0", false},
+ {"\U0001f1f3\U0001f1f1", "flag: Netherlands", []string{"netherlands"}, "6.0", false},
+ {"\U0001f610", "neutral face", []string{"neutral_face"}, "6.0", false},
+ {"\U0001f195", "NEW button", []string{"new"}, "6.0", false},
+ {"\U0001f1f3\U0001f1e8", "flag: New Caledonia", []string{"new_caledonia"}, "6.0", false},
+ {"\U0001f311", "new moon", []string{"new_moon"}, "6.0", false},
+ {"\U0001f31a", "new moon face", []string{"new_moon_with_face"}, "6.0", false},
+ {"\U0001f1f3\U0001f1ff", "flag: New Zealand", []string{"new_zealand"}, "6.0", false},
+ {"\U0001f4f0", "newspaper", []string{"newspaper"}, "6.0", false},
+ {"\U0001f5de\ufe0f", "rolled-up newspaper", []string{"newspaper_roll"}, "7.0", false},
+ {"\u23ed\ufe0f", "next track button", []string{"next_track_button"}, "6.0", false},
+ {"\U0001f196", "NG button", []string{"ng"}, "6.0", false},
+ {"\U0001f1f3\U0001f1ee", "flag: Nicaragua", []string{"nicaragua"}, "6.0", false},
+ {"\U0001f1f3\U0001f1ea", "flag: Niger", []string{"niger"}, "6.0", false},
+ {"\U0001f1f3\U0001f1ec", "flag: Nigeria", []string{"nigeria"}, "6.0", false},
+ {"\U0001f303", "night with stars", []string{"night_with_stars"}, "6.0", false},
+ {"9\ufe0f\u20e3", "keycap: 9", []string{"nine"}, "", false},
+ {"\U0001f977", "ninja", []string{"ninja"}, "13.0", true},
+ {"\U0001f977\U0001f3ff", "ninja: Dark Skin Tone", []string{"ninja_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f977\U0001f3fb", "ninja: Light Skin Tone", []string{"ninja_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f977\U0001f3fe", "ninja: Medium-Dark Skin Tone", []string{"ninja_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f977\U0001f3fc", "ninja: Medium-Light Skin Tone", []string{"ninja_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f977\U0001f3fd", "ninja: Medium Skin Tone", []string{"ninja_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f3\U0001f1fa", "flag: Niue", []string{"niue"}, "6.0", false},
+ {"\U0001f515", "bell with slash", []string{"no_bell"}, "6.0", false},
+ {"\U0001f6b3", "no bicycles", []string{"no_bicycles"}, "6.0", false},
+ {"\u26d4", "no entry", []string{"no_entry"}, "5.2", false},
+ {"\U0001f6ab", "prohibited", []string{"no_entry_sign"}, "6.0", false},
+ {"\U0001f645", "person gesturing NO", []string{"no_good"}, "6.0", true},
+ {"\U0001f645\U0001f3ff", "person gesturing NO: Dark Skin Tone", []string{"no_good_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fb", "person gesturing NO: Light Skin Tone", []string{"no_good_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fe", "person gesturing NO: Medium-Dark Skin Tone", []string{"no_good_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fc", "person gesturing NO: Medium-Light Skin Tone", []string{"no_good_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fd", "person gesturing NO: Medium Skin Tone", []string{"no_good_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\u200d\u2642\ufe0f", "man gesturing NO", []string{"no_good_man", "ng_man"}, "6.0", true},
+ {"\U0001f645\U0001f3ff\u200d\u2642\ufe0f", "man gesturing NO: Dark Skin Tone", []string{"no_good_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fb\u200d\u2642\ufe0f", "man gesturing NO: Light Skin Tone", []string{"no_good_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fe\u200d\u2642\ufe0f", "man gesturing NO: Medium-Dark Skin Tone", []string{"no_good_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fc\u200d\u2642\ufe0f", "man gesturing NO: Medium-Light Skin Tone", []string{"no_good_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fd\u200d\u2642\ufe0f", "man gesturing NO: Medium Skin Tone", []string{"no_good_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\u200d\u2640\ufe0f", "woman gesturing NO", []string{"no_good_woman", "ng_woman"}, "11.0", true},
+ {"\U0001f645\U0001f3ff\u200d\u2640\ufe0f", "woman gesturing NO: Dark Skin Tone", []string{"no_good_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fb\u200d\u2640\ufe0f", "woman gesturing NO: Light Skin Tone", []string{"no_good_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fe\u200d\u2640\ufe0f", "woman gesturing NO: Medium-Dark Skin Tone", []string{"no_good_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fc\u200d\u2640\ufe0f", "woman gesturing NO: Medium-Light Skin Tone", []string{"no_good_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f645\U0001f3fd\u200d\u2640\ufe0f", "woman gesturing NO: Medium Skin Tone", []string{"no_good_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4f5", "no mobile phones", []string{"no_mobile_phones"}, "6.0", false},
+ {"\U0001f636", "face without mouth", []string{"no_mouth"}, "6.0", false},
+ {"\U0001f6b7", "no pedestrians", []string{"no_pedestrians"}, "6.0", false},
+ {"\U0001f6ad", "no smoking", []string{"no_smoking"}, "6.0", false},
+ {"\U0001f6b1", "non-potable water", []string{"non-potable_water"}, "6.0", false},
+ {"\U0001f1f3\U0001f1eb", "flag: Norfolk Island", []string{"norfolk_island"}, "6.0", false},
+ {"\U0001f1f0\U0001f1f5", "flag: North Korea", []string{"north_korea"}, "6.0", false},
+ {"\U0001f1f2\U0001f1f5", "flag: Northern Mariana Islands", []string{"northern_mariana_islands"}, "6.0", false},
+ {"\U0001f1f3\U0001f1f4", "flag: Norway", []string{"norway"}, "6.0", false},
+ {"\U0001f443", "nose", []string{"nose"}, "6.0", true},
+ {"\U0001f443\U0001f3ff", "nose: Dark Skin Tone", []string{"nose_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f443\U0001f3fb", "nose: Light Skin Tone", []string{"nose_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f443\U0001f3fe", "nose: Medium-Dark Skin Tone", []string{"nose_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f443\U0001f3fc", "nose: Medium-Light Skin Tone", []string{"nose_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f443\U0001f3fd", "nose: Medium Skin Tone", []string{"nose_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4d3", "notebook", []string{"notebook"}, "6.0", false},
+ {"\U0001f4d4", "notebook with decorative cover", []string{"notebook_with_decorative_cover"}, "6.0", false},
+ {"\U0001f3b6", "musical notes", []string{"notes"}, "6.0", false},
+ {"\U0001f529", "nut and bolt", []string{"nut_and_bolt"}, "6.0", false},
+ {"\u2b55", "hollow red circle", []string{"o"}, "5.2", false},
+ {"\U0001f17e\ufe0f", "O button (blood type)", []string{"o2"}, "6.0", false},
+ {"\U0001f30a", "water wave", []string{"ocean"}, "6.0", false},
+ {"\U0001f419", "octopus", []string{"octopus"}, "6.0", false},
+ {"\U0001f362", "oden", []string{"oden"}, "6.0", false},
+ {"\U0001f3e2", "office building", []string{"office"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f4bc", "office worker", []string{"office_worker"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f4bc", "office worker: Dark Skin Tone", []string{"office_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f4bc", "office worker: Light Skin Tone", []string{"office_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f4bc", "office worker: Medium-Dark Skin Tone", []string{"office_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f4bc", "office worker: Medium-Light Skin Tone", []string{"office_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f4bc", "office worker: Medium Skin Tone", []string{"office_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6e2\ufe0f", "oil drum", []string{"oil_drum"}, "7.0", false},
+ {"\U0001f197", "OK button", []string{"ok"}, "6.0", false},
+ {"\U0001f44c", "OK hand", []string{"ok_hand"}, "6.0", true},
+ {"\U0001f44c\U0001f3ff", "OK hand: Dark Skin Tone", []string{"ok_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44c\U0001f3fb", "OK hand: Light Skin Tone", []string{"ok_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44c\U0001f3fe", "OK hand: Medium-Dark Skin Tone", []string{"ok_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44c\U0001f3fc", "OK hand: Medium-Light Skin Tone", []string{"ok_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44c\U0001f3fd", "OK hand: Medium Skin Tone", []string{"ok_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\u200d\u2642\ufe0f", "man gesturing OK", []string{"ok_man"}, "6.0", true},
+ {"\U0001f646\U0001f3ff\u200d\u2642\ufe0f", "man gesturing OK: Dark Skin Tone", []string{"ok_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fb\u200d\u2642\ufe0f", "man gesturing OK: Light Skin Tone", []string{"ok_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fe\u200d\u2642\ufe0f", "man gesturing OK: Medium-Dark Skin Tone", []string{"ok_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fc\u200d\u2642\ufe0f", "man gesturing OK: Medium-Light Skin Tone", []string{"ok_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fd\u200d\u2642\ufe0f", "man gesturing OK: Medium Skin Tone", []string{"ok_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f646", "person gesturing OK", []string{"ok_person"}, "6.0", true},
+ {"\U0001f646\U0001f3ff", "person gesturing OK: Dark Skin Tone", []string{"ok_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fb", "person gesturing OK: Light Skin Tone", []string{"ok_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fe", "person gesturing OK: Medium-Dark Skin Tone", []string{"ok_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fc", "person gesturing OK: Medium-Light Skin Tone", []string{"ok_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fd", "person gesturing OK: Medium Skin Tone", []string{"ok_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\u200d\u2640\ufe0f", "woman gesturing OK", []string{"ok_woman"}, "11.0", true},
+ {"\U0001f646\U0001f3ff\u200d\u2640\ufe0f", "woman gesturing OK: Dark Skin Tone", []string{"ok_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fb\u200d\u2640\ufe0f", "woman gesturing OK: Light Skin Tone", []string{"ok_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fe\u200d\u2640\ufe0f", "woman gesturing OK: Medium-Dark Skin Tone", []string{"ok_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fc\u200d\u2640\ufe0f", "woman gesturing OK: Medium-Light Skin Tone", []string{"ok_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f646\U0001f3fd\u200d\u2640\ufe0f", "woman gesturing OK: Medium Skin Tone", []string{"ok_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f5dd\ufe0f", "old key", []string{"old_key"}, "7.0", false},
+ {"\U0001f9d3", "older person", []string{"older_adult"}, "11.0", true},
+ {"\U0001f9d3\U0001f3ff", "older person: Dark Skin Tone", []string{"older_adult_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d3\U0001f3fb", "older person: Light Skin Tone", []string{"older_adult_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d3\U0001f3fe", "older person: Medium-Dark Skin Tone", []string{"older_adult_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d3\U0001f3fc", "older person: Medium-Light Skin Tone", []string{"older_adult_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d3\U0001f3fd", "older person: Medium Skin Tone", []string{"older_adult_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f474", "old man", []string{"older_man"}, "6.0", true},
+ {"\U0001f474\U0001f3ff", "old man: Dark Skin Tone", []string{"older_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f474\U0001f3fb", "old man: Light Skin Tone", []string{"older_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f474\U0001f3fe", "old man: Medium-Dark Skin Tone", []string{"older_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f474\U0001f3fc", "old man: Medium-Light Skin Tone", []string{"older_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f474\U0001f3fd", "old man: Medium Skin Tone", []string{"older_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f475", "old woman", []string{"older_woman"}, "6.0", true},
+ {"\U0001f475\U0001f3ff", "old woman: Dark Skin Tone", []string{"older_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f475\U0001f3fb", "old woman: Light Skin Tone", []string{"older_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f475\U0001f3fe", "old woman: Medium-Dark Skin Tone", []string{"older_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f475\U0001f3fc", "old woman: Medium-Light Skin Tone", []string{"older_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f475\U0001f3fd", "old woman: Medium Skin Tone", []string{"older_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fad2", "olive", []string{"olive"}, "13.0", false},
+ {"\U0001f549\ufe0f", "om", []string{"om"}, "7.0", false},
+ {"\U0001f1f4\U0001f1f2", "flag: Oman", []string{"oman"}, "6.0", false},
+ {"\U0001f51b", "ON! arrow", []string{"on"}, "6.0", false},
+ {"\U0001f698", "oncoming automobile", []string{"oncoming_automobile"}, "6.0", false},
+ {"\U0001f68d", "oncoming bus", []string{"oncoming_bus"}, "6.0", false},
+ {"\U0001f694", "oncoming police car", []string{"oncoming_police_car"}, "6.0", false},
+ {"\U0001f696", "oncoming taxi", []string{"oncoming_taxi"}, "6.0", false},
+ {"1\ufe0f\u20e3", "keycap: 1", []string{"one"}, "", false},
+ {"\U0001fa71", "one-piece swimsuit", []string{"one_piece_swimsuit"}, "12.0", false},
+ {"\U0001f9c5", "onion", []string{"onion"}, "12.0", false},
+ {"\U0001f4c2", "open file folder", []string{"open_file_folder"}, "6.0", false},
+ {"\U0001f450", "open hands", []string{"open_hands"}, "6.0", true},
+ {"\U0001f450\U0001f3ff", "open hands: Dark Skin Tone", []string{"open_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f450\U0001f3fb", "open hands: Light Skin Tone", []string{"open_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f450\U0001f3fe", "open hands: Medium-Dark Skin Tone", []string{"open_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f450\U0001f3fc", "open hands: Medium-Light Skin Tone", []string{"open_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f450\U0001f3fd", "open hands: Medium Skin Tone", []string{"open_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f62e", "face with open mouth", []string{"open_mouth"}, "6.1", false},
+ {"\u2602\ufe0f", "umbrella", []string{"open_umbrella"}, "", false},
+ {"\u26ce", "Ophiuchus", []string{"ophiuchus"}, "6.0", false},
+ {"\U0001f4d9", "orange book", []string{"orange_book"}, "6.0", false},
+ {"\U0001f7e0", "orange circle", []string{"orange_circle"}, "12.0", false},
+ {"\U0001f9e1", "orange heart", []string{"orange_heart"}, "11.0", false},
+ {"\U0001f7e7", "orange square", []string{"orange_square"}, "12.0", false},
+ {"\U0001f9a7", "orangutan", []string{"orangutan"}, "12.0", false},
+ {"\u2626\ufe0f", "orthodox cross", []string{"orthodox_cross"}, "", false},
+ {"\U0001f9a6", "otter", []string{"otter"}, "12.0", false},
+ {"\U0001f4e4", "outbox tray", []string{"outbox_tray"}, "6.0", false},
+ {"\U0001f989", "owl", []string{"owl"}, "9.0", false},
+ {"\U0001f402", "ox", []string{"ox"}, "6.0", false},
+ {"\U0001f9aa", "oyster", []string{"oyster"}, "12.0", false},
+ {"\U0001f4e6", "package", []string{"package"}, "6.0", false},
+ {"\U0001f4c4", "page facing up", []string{"page_facing_up"}, "6.0", false},
+ {"\U0001f4c3", "page with curl", []string{"page_with_curl"}, "6.0", false},
+ {"\U0001f4df", "pager", []string{"pager"}, "6.0", false},
+ {"\U0001f58c\ufe0f", "paintbrush", []string{"paintbrush"}, "7.0", false},
+ {"\U0001f1f5\U0001f1f0", "flag: Pakistan", []string{"pakistan"}, "6.0", false},
+ {"\U0001f1f5\U0001f1fc", "flag: Palau", []string{"palau"}, "6.0", false},
+ {"\U0001f1f5\U0001f1f8", "flag: Palestinian Territories", []string{"palestinian_territories"}, "6.0", false},
+ {"\U0001faf3", "palm down hand", []string{"palm_down_hand"}, "14.0", true},
+ {"\U0001faf3\U0001f3ff", "palm down hand: Dark Skin Tone", []string{"palm_down_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf3\U0001f3fb", "palm down hand: Light Skin Tone", []string{"palm_down_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf3\U0001f3fe", "palm down hand: Medium-Dark Skin Tone", []string{"palm_down_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf3\U0001f3fc", "palm down hand: Medium-Light Skin Tone", []string{"palm_down_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf3\U0001f3fd", "palm down hand: Medium Skin Tone", []string{"palm_down_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f334", "palm tree", []string{"palm_tree"}, "6.0", false},
+ {"\U0001faf4", "palm up hand", []string{"palm_up_hand"}, "14.0", true},
+ {"\U0001faf4\U0001f3ff", "palm up hand: Dark Skin Tone", []string{"palm_up_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf4\U0001f3fb", "palm up hand: Light Skin Tone", []string{"palm_up_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf4\U0001f3fe", "palm up hand: Medium-Dark Skin Tone", []string{"palm_up_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf4\U0001f3fc", "palm up hand: Medium-Light Skin Tone", []string{"palm_up_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf4\U0001f3fd", "palm up hand: Medium Skin Tone", []string{"palm_up_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f932", "palms up together", []string{"palms_up_together"}, "11.0", true},
+ {"\U0001f932\U0001f3ff", "palms up together: Dark Skin Tone", []string{"palms_up_together_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f932\U0001f3fb", "palms up together: Light Skin Tone", []string{"palms_up_together_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f932\U0001f3fe", "palms up together: Medium-Dark Skin Tone", []string{"palms_up_together_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f932\U0001f3fc", "palms up together: Medium-Light Skin Tone", []string{"palms_up_together_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f932\U0001f3fd", "palms up together: Medium Skin Tone", []string{"palms_up_together_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f5\U0001f1e6", "flag: Panama", []string{"panama"}, "6.0", false},
+ {"\U0001f95e", "pancakes", []string{"pancakes"}, "9.0", false},
+ {"\U0001f43c", "panda", []string{"panda_face"}, "6.0", false},
+ {"\U0001f4ce", "paperclip", []string{"paperclip"}, "6.0", false},
+ {"\U0001f587\ufe0f", "linked paperclips", []string{"paperclips"}, "7.0", false},
+ {"\U0001f1f5\U0001f1ec", "flag: Papua New Guinea", []string{"papua_new_guinea"}, "6.0", false},
+ {"\U0001fa82", "parachute", []string{"parachute"}, "12.0", false},
+ {"\U0001f1f5\U0001f1fe", "flag: Paraguay", []string{"paraguay"}, "6.0", false},
+ {"\u26f1\ufe0f", "umbrella on ground", []string{"parasol_on_ground"}, "5.2", false},
+ {"\U0001f17f\ufe0f", "P button", []string{"parking"}, "5.2", false},
+ {"\U0001f99c", "parrot", []string{"parrot"}, "11.0", false},
+ {"\u303d\ufe0f", "part alternation mark", []string{"part_alternation_mark"}, "3.2", false},
+ {"\u26c5", "sun behind cloud", []string{"partly_sunny"}, "5.2", false},
+ {"\U0001f973", "partying face", []string{"partying_face"}, "11.0", false},
+ {"\U0001f6f3\ufe0f", "passenger ship", []string{"passenger_ship"}, "7.0", false},
+ {"\U0001f6c2", "passport control", []string{"passport_control"}, "6.0", false},
+ {"\u23f8\ufe0f", "pause button", []string{"pause_button"}, "7.0", false},
+ {"\U0001fadb", "pea pod", []string{"pea_pod"}, "15.0", false},
+ {"\u262e\ufe0f", "peace symbol", []string{"peace_symbol"}, "", false},
+ {"\U0001f351", "peach", []string{"peach"}, "6.0", false},
+ {"\U0001f99a", "peacock", []string{"peacock"}, "11.0", false},
+ {"\U0001f95c", "peanuts", []string{"peanuts"}, "9.0", false},
+ {"\U0001f350", "pear", []string{"pear"}, "6.0", false},
+ {"\U0001f58a\ufe0f", "pen", []string{"pen"}, "7.0", false},
+ {"\u270f\ufe0f", "pencil", []string{"pencil2"}, "", false},
+ {"\U0001f427", "penguin", []string{"penguin"}, "6.0", false},
+ {"\U0001f614", "pensive face", []string{"pensive"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands", []string{"people_holding_hands"}, "12.0", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands: Dark Skin Tone", []string{"people_holding_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands: Light Skin Tone", []string{"people_holding_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands: Medium-Dark Skin Tone", []string{"people_holding_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands: Medium-Light Skin Tone", []string{"people_holding_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f91d\u200d\U0001f9d1", "people holding hands: Medium Skin Tone", []string{"people_holding_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fac2", "people hugging", []string{"people_hugging"}, "13.0", false},
+ {"\U0001f3ad", "performing arts", []string{"performing_arts"}, "6.0", false},
+ {"\U0001f623", "persevering face", []string{"persevere"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f9b2", "person: bald", []string{"person_bald"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9b2", "person: bald: Dark Skin Tone", []string{"person_bald_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9b2", "person: bald: Light Skin Tone", []string{"person_bald_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9b2", "person: bald: Medium-Dark Skin Tone", []string{"person_bald_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9b2", "person: bald: Medium-Light Skin Tone", []string{"person_bald_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9b2", "person: bald: Medium Skin Tone", []string{"person_bald_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f9b1", "person: curly hair", []string{"person_curly_hair"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9b1", "person: curly hair: Dark Skin Tone", []string{"person_curly_hair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9b1", "person: curly hair: Light Skin Tone", []string{"person_curly_hair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9b1", "person: curly hair: Medium-Dark Skin Tone", []string{"person_curly_hair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9b1", "person: curly hair: Medium-Light Skin Tone", []string{"person_curly_hair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9b1", "person: curly hair: Medium Skin Tone", []string{"person_curly_hair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f37c", "person feeding baby", []string{"person_feeding_baby"}, "13.0", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f37c", "person feeding baby: Dark Skin Tone", []string{"person_feeding_baby_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f37c", "person feeding baby: Light Skin Tone", []string{"person_feeding_baby_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f37c", "person feeding baby: Medium-Dark Skin Tone", []string{"person_feeding_baby_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f37c", "person feeding baby: Medium-Light Skin Tone", []string{"person_feeding_baby_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f37c", "person feeding baby: Medium Skin Tone", []string{"person_feeding_baby_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f93a", "person fencing", []string{"person_fencing"}, "9.0", false},
+ {"\U0001f9d1\u200d\U0001f9bd", "person in manual wheelchair", []string{"person_in_manual_wheelchair"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9bd", "person in manual wheelchair: Dark Skin Tone", []string{"person_in_manual_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9bd", "person in manual wheelchair: Light Skin Tone", []string{"person_in_manual_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9bd", "person in manual wheelchair: Medium-Dark Skin Tone", []string{"person_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9bd", "person in manual wheelchair: Medium-Light Skin Tone", []string{"person_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9bd", "person in manual wheelchair: Medium Skin Tone", []string{"person_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f9bc", "person in motorized wheelchair", []string{"person_in_motorized_wheelchair"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9bc", "person in motorized wheelchair: Dark Skin Tone", []string{"person_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9bc", "person in motorized wheelchair: Light Skin Tone", []string{"person_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Dark Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Light Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9bc", "person in motorized wheelchair: Medium Skin Tone", []string{"person_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f935", "person in tuxedo", []string{"person_in_tuxedo"}, "9.0", true},
+ {"\U0001f935\U0001f3ff", "person in tuxedo: Dark Skin Tone", []string{"person_in_tuxedo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fb", "person in tuxedo: Light Skin Tone", []string{"person_in_tuxedo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fe", "person in tuxedo: Medium-Dark Skin Tone", []string{"person_in_tuxedo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fc", "person in tuxedo: Medium-Light Skin Tone", []string{"person_in_tuxedo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fd", "person in tuxedo: Medium Skin Tone", []string{"person_in_tuxedo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f9b0", "person: red hair", []string{"person_red_hair"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9b0", "person: red hair: Dark Skin Tone", []string{"person_red_hair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9b0", "person: red hair: Light Skin Tone", []string{"person_red_hair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9b0", "person: red hair: Medium-Dark Skin Tone", []string{"person_red_hair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9b0", "person: red hair: Medium-Light Skin Tone", []string{"person_red_hair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9b0", "person: red hair: Medium Skin Tone", []string{"person_red_hair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f9b3", "person: white hair", []string{"person_white_hair"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9b3", "person: white hair: Dark Skin Tone", []string{"person_white_hair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9b3", "person: white hair: Light Skin Tone", []string{"person_white_hair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9b3", "person: white hair: Medium-Dark Skin Tone", []string{"person_white_hair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9b3", "person: white hair: Medium-Light Skin Tone", []string{"person_white_hair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9b3", "person: white hair: Medium Skin Tone", []string{"person_white_hair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fac5", "person with crown", []string{"person_with_crown"}, "14.0", true},
+ {"\U0001fac5\U0001f3ff", "person with crown: Dark Skin Tone", []string{"person_with_crown_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac5\U0001f3fb", "person with crown: Light Skin Tone", []string{"person_with_crown_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac5\U0001f3fe", "person with crown: Medium-Dark Skin Tone", []string{"person_with_crown_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac5\U0001f3fc", "person with crown: Medium-Light Skin Tone", []string{"person_with_crown_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac5\U0001f3fd", "person with crown: Medium Skin Tone", []string{"person_with_crown_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\u200d\U0001f9af", "person with white cane", []string{"person_with_probing_cane"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f9af", "person with white cane: Dark Skin Tone", []string{"person_with_probing_cane_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f9af", "person with white cane: Light Skin Tone", []string{"person_with_probing_cane_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f9af", "person with white cane: Medium-Dark Skin Tone", []string{"person_with_probing_cane_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f9af", "person with white cane: Medium-Light Skin Tone", []string{"person_with_probing_cane_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f9af", "person with white cane: Medium Skin Tone", []string{"person_with_probing_cane_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f473", "person wearing turban", []string{"person_with_turban"}, "6.0", true},
+ {"\U0001f473\U0001f3ff", "person wearing turban: Dark Skin Tone", []string{"person_with_turban_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fb", "person wearing turban: Light Skin Tone", []string{"person_with_turban_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fe", "person wearing turban: Medium-Dark Skin Tone", []string{"person_with_turban_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fc", "person wearing turban: Medium-Light Skin Tone", []string{"person_with_turban_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fd", "person wearing turban: Medium Skin Tone", []string{"person_with_turban_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f470", "person with veil", []string{"person_with_veil"}, "6.0", true},
+ {"\U0001f470\U0001f3ff", "person with veil: Dark Skin Tone", []string{"person_with_veil_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fb", "person with veil: Light Skin Tone", []string{"person_with_veil_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fe", "person with veil: Medium-Dark Skin Tone", []string{"person_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fc", "person with veil: Medium-Light Skin Tone", []string{"person_with_veil_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fd", "person with veil: Medium Skin Tone", []string{"person_with_veil_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f5\U0001f1ea", "flag: Peru", []string{"peru"}, "6.0", false},
+ {"\U0001f9eb", "petri dish", []string{"petri_dish"}, "11.0", false},
+ {"\U0001f1f5\U0001f1ed", "flag: Philippines", []string{"philippines"}, "6.0", false},
+ {"\u260e\ufe0f", "telephone", []string{"phone", "telephone"}, "", false},
+ {"\u26cf\ufe0f", "pick", []string{"pick"}, "5.2", false},
+ {"\U0001f6fb", "pickup truck", []string{"pickup_truck"}, "13.0", false},
+ {"\U0001f967", "pie", []string{"pie"}, "11.0", false},
+ {"\U0001f437", "pig face", []string{"pig"}, "6.0", false},
+ {"\U0001f416", "pig", []string{"pig2"}, "6.0", false},
+ {"\U0001f43d", "pig nose", []string{"pig_nose"}, "6.0", false},
+ {"\U0001f48a", "pill", []string{"pill"}, "6.0", false},
+ {"\U0001f9d1\u200d\u2708\ufe0f", "pilot", []string{"pilot"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\u2708\ufe0f", "pilot: Dark Skin Tone", []string{"pilot_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\u2708\ufe0f", "pilot: Light Skin Tone", []string{"pilot_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\u2708\ufe0f", "pilot: Medium-Dark Skin Tone", []string{"pilot_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\u2708\ufe0f", "pilot: Medium-Light Skin Tone", []string{"pilot_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\u2708\ufe0f", "pilot: Medium Skin Tone", []string{"pilot_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fa85", "piñata", []string{"pinata"}, "13.0", false},
+ {"\U0001f90c", "pinched fingers", []string{"pinched_fingers"}, "13.0", true},
+ {"\U0001f90c\U0001f3ff", "pinched fingers: Dark Skin Tone", []string{"pinched_fingers_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f90c\U0001f3fb", "pinched fingers: Light Skin Tone", []string{"pinched_fingers_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f90c\U0001f3fe", "pinched fingers: Medium-Dark Skin Tone", []string{"pinched_fingers_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f90c\U0001f3fc", "pinched fingers: Medium-Light Skin Tone", []string{"pinched_fingers_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f90c\U0001f3fd", "pinched fingers: Medium Skin Tone", []string{"pinched_fingers_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f90f", "pinching hand", []string{"pinching_hand"}, "12.0", true},
+ {"\U0001f90f\U0001f3ff", "pinching hand: Dark Skin Tone", []string{"pinching_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f90f\U0001f3fb", "pinching hand: Light Skin Tone", []string{"pinching_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f90f\U0001f3fe", "pinching hand: Medium-Dark Skin Tone", []string{"pinching_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f90f\U0001f3fc", "pinching hand: Medium-Light Skin Tone", []string{"pinching_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f90f\U0001f3fd", "pinching hand: Medium Skin Tone", []string{"pinching_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f34d", "pineapple", []string{"pineapple"}, "6.0", false},
+ {"\U0001f3d3", "ping pong", []string{"ping_pong"}, "8.0", false},
+ {"\U0001fa77", "pink heart", []string{"pink_heart"}, "15.0", false},
+ {"\U0001f3f4\u200d\u2620\ufe0f", "pirate flag", []string{"pirate_flag"}, "11.0", false},
+ {"\u2653", "Pisces", []string{"pisces"}, "", false},
+ {"\U0001f1f5\U0001f1f3", "flag: Pitcairn Islands", []string{"pitcairn_islands"}, "6.0", false},
+ {"\U0001f355", "pizza", []string{"pizza"}, "6.0", false},
+ {"\U0001faa7", "placard", []string{"placard"}, "13.0", false},
+ {"\U0001f6d0", "place of worship", []string{"place_of_worship"}, "8.0", false},
+ {"\U0001f37d\ufe0f", "fork and knife with plate", []string{"plate_with_cutlery"}, "7.0", false},
+ {"\u23ef\ufe0f", "play or pause button", []string{"play_or_pause_button"}, "6.0", false},
+ {"\U0001f6dd", "playground slide", []string{"playground_slide"}, "14.0", false},
+ {"\U0001f97a", "pleading face", []string{"pleading_face"}, "11.0", false},
+ {"\U0001faa0", "plunger", []string{"plunger"}, "13.0", false},
+ {"\U0001f447", "backhand index pointing down", []string{"point_down"}, "6.0", true},
+ {"\U0001f447\U0001f3ff", "backhand index pointing down: Dark Skin Tone", []string{"point_down_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f447\U0001f3fb", "backhand index pointing down: Light Skin Tone", []string{"point_down_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f447\U0001f3fe", "backhand index pointing down: Medium-Dark Skin Tone", []string{"point_down_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f447\U0001f3fc", "backhand index pointing down: Medium-Light Skin Tone", []string{"point_down_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f447\U0001f3fd", "backhand index pointing down: Medium Skin Tone", []string{"point_down_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f448", "backhand index pointing left", []string{"point_left"}, "6.0", true},
+ {"\U0001f448\U0001f3ff", "backhand index pointing left: Dark Skin Tone", []string{"point_left_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f448\U0001f3fb", "backhand index pointing left: Light Skin Tone", []string{"point_left_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f448\U0001f3fe", "backhand index pointing left: Medium-Dark Skin Tone", []string{"point_left_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f448\U0001f3fc", "backhand index pointing left: Medium-Light Skin Tone", []string{"point_left_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f448\U0001f3fd", "backhand index pointing left: Medium Skin Tone", []string{"point_left_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f449", "backhand index pointing right", []string{"point_right"}, "6.0", true},
+ {"\U0001f449\U0001f3ff", "backhand index pointing right: Dark Skin Tone", []string{"point_right_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f449\U0001f3fb", "backhand index pointing right: Light Skin Tone", []string{"point_right_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f449\U0001f3fe", "backhand index pointing right: Medium-Dark Skin Tone", []string{"point_right_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f449\U0001f3fc", "backhand index pointing right: Medium-Light Skin Tone", []string{"point_right_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f449\U0001f3fd", "backhand index pointing right: Medium Skin Tone", []string{"point_right_Medium_Skin_Tone"}, "12.0", false},
+ {"\u261d\ufe0f", "index pointing up", []string{"point_up"}, "", true},
+ {"\U0001f446", "backhand index pointing up", []string{"point_up_2"}, "6.0", true},
+ {"\U0001f446\U0001f3ff", "backhand index pointing up: Dark Skin Tone", []string{"point_up_2_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f446\U0001f3fb", "backhand index pointing up: Light Skin Tone", []string{"point_up_2_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f446\U0001f3fe", "backhand index pointing up: Medium-Dark Skin Tone", []string{"point_up_2_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f446\U0001f3fc", "backhand index pointing up: Medium-Light Skin Tone", []string{"point_up_2_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f446\U0001f3fd", "backhand index pointing up: Medium Skin Tone", []string{"point_up_2_Medium_Skin_Tone"}, "12.0", false},
+ {"\u261d\U0001f3ff\ufe0f", "index pointing up: Dark Skin Tone", []string{"point_up_Dark_Skin_Tone"}, "12.0", false},
+ {"\u261d\U0001f3fb\ufe0f", "index pointing up: Light Skin Tone", []string{"point_up_Light_Skin_Tone"}, "12.0", false},
+ {"\u261d\U0001f3fe\ufe0f", "index pointing up: Medium-Dark Skin Tone", []string{"point_up_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u261d\U0001f3fc\ufe0f", "index pointing up: Medium-Light Skin Tone", []string{"point_up_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u261d\U0001f3fd\ufe0f", "index pointing up: Medium Skin Tone", []string{"point_up_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f5\U0001f1f1", "flag: Poland", []string{"poland"}, "6.0", false},
+ {"\U0001f43b\u200d\u2744\ufe0f", "polar bear", []string{"polar_bear"}, "13.0", false},
+ {"\U0001f693", "police car", []string{"police_car"}, "6.0", false},
+ {"\U0001f46e", "police officer", []string{"police_officer", "cop"}, "6.0", true},
+ {"\U0001f46e\U0001f3ff", "police officer: Dark Skin Tone", []string{"police_officer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fb", "police officer: Light Skin Tone", []string{"police_officer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fe", "police officer: Medium-Dark Skin Tone", []string{"police_officer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fc", "police officer: Medium-Light Skin Tone", []string{"police_officer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fd", "police officer: Medium Skin Tone", []string{"police_officer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\u200d\u2642\ufe0f", "man police officer", []string{"policeman"}, "11.0", true},
+ {"\U0001f46e\U0001f3ff\u200d\u2642\ufe0f", "man police officer: Dark Skin Tone", []string{"policeman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fb\u200d\u2642\ufe0f", "man police officer: Light Skin Tone", []string{"policeman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fe\u200d\u2642\ufe0f", "man police officer: Medium-Dark Skin Tone", []string{"policeman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fc\u200d\u2642\ufe0f", "man police officer: Medium-Light Skin Tone", []string{"policeman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fd\u200d\u2642\ufe0f", "man police officer: Medium Skin Tone", []string{"policeman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\u200d\u2640\ufe0f", "woman police officer", []string{"policewoman"}, "6.0", true},
+ {"\U0001f46e\U0001f3ff\u200d\u2640\ufe0f", "woman police officer: Dark Skin Tone", []string{"policewoman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fb\u200d\u2640\ufe0f", "woman police officer: Light Skin Tone", []string{"policewoman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fe\u200d\u2640\ufe0f", "woman police officer: Medium-Dark Skin Tone", []string{"policewoman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fc\u200d\u2640\ufe0f", "woman police officer: Medium-Light Skin Tone", []string{"policewoman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46e\U0001f3fd\u200d\u2640\ufe0f", "woman police officer: Medium Skin Tone", []string{"policewoman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f429", "poodle", []string{"poodle"}, "6.0", false},
+ {"\U0001f37f", "popcorn", []string{"popcorn"}, "8.0", false},
+ {"\U0001f1f5\U0001f1f9", "flag: Portugal", []string{"portugal"}, "6.0", false},
+ {"\U0001f3e3", "Japanese post office", []string{"post_office"}, "6.0", false},
+ {"\U0001f4ef", "postal horn", []string{"postal_horn"}, "6.0", false},
+ {"\U0001f4ee", "postbox", []string{"postbox"}, "6.0", false},
+ {"\U0001f6b0", "potable water", []string{"potable_water"}, "6.0", false},
+ {"\U0001f954", "potato", []string{"potato"}, "9.0", false},
+ {"\U0001fab4", "potted plant", []string{"potted_plant"}, "13.0", false},
+ {"\U0001f45d", "clutch bag", []string{"pouch"}, "6.0", false},
+ {"\U0001f357", "poultry leg", []string{"poultry_leg"}, "6.0", false},
+ {"\U0001f4b7", "pound banknote", []string{"pound"}, "6.0", false},
+ {"\U0001fad7", "pouring liquid", []string{"pouring_liquid"}, "14.0", false},
+ {"\U0001f63e", "pouting cat", []string{"pouting_cat"}, "6.0", false},
+ {"\U0001f64e", "person pouting", []string{"pouting_face"}, "6.0", true},
+ {"\U0001f64e\U0001f3ff", "person pouting: Dark Skin Tone", []string{"pouting_face_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fb", "person pouting: Light Skin Tone", []string{"pouting_face_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fe", "person pouting: Medium-Dark Skin Tone", []string{"pouting_face_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fc", "person pouting: Medium-Light Skin Tone", []string{"pouting_face_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fd", "person pouting: Medium Skin Tone", []string{"pouting_face_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\u200d\u2642\ufe0f", "man pouting", []string{"pouting_man"}, "6.0", true},
+ {"\U0001f64e\U0001f3ff\u200d\u2642\ufe0f", "man pouting: Dark Skin Tone", []string{"pouting_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fb\u200d\u2642\ufe0f", "man pouting: Light Skin Tone", []string{"pouting_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fe\u200d\u2642\ufe0f", "man pouting: Medium-Dark Skin Tone", []string{"pouting_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fc\u200d\u2642\ufe0f", "man pouting: Medium-Light Skin Tone", []string{"pouting_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fd\u200d\u2642\ufe0f", "man pouting: Medium Skin Tone", []string{"pouting_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\u200d\u2640\ufe0f", "woman pouting", []string{"pouting_woman"}, "11.0", true},
+ {"\U0001f64e\U0001f3ff\u200d\u2640\ufe0f", "woman pouting: Dark Skin Tone", []string{"pouting_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fb\u200d\u2640\ufe0f", "woman pouting: Light Skin Tone", []string{"pouting_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fe\u200d\u2640\ufe0f", "woman pouting: Medium-Dark Skin Tone", []string{"pouting_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fc\u200d\u2640\ufe0f", "woman pouting: Medium-Light Skin Tone", []string{"pouting_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64e\U0001f3fd\u200d\u2640\ufe0f", "woman pouting: Medium Skin Tone", []string{"pouting_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64f", "folded hands", []string{"pray"}, "6.0", true},
+ {"\U0001f64f\U0001f3ff", "folded hands: Dark Skin Tone", []string{"pray_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64f\U0001f3fb", "folded hands: Light Skin Tone", []string{"pray_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64f\U0001f3fe", "folded hands: Medium-Dark Skin Tone", []string{"pray_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64f\U0001f3fc", "folded hands: Medium-Light Skin Tone", []string{"pray_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64f\U0001f3fd", "folded hands: Medium Skin Tone", []string{"pray_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f4ff", "prayer beads", []string{"prayer_beads"}, "8.0", false},
+ {"\U0001fac3", "pregnant man", []string{"pregnant_man"}, "14.0", true},
+ {"\U0001fac3\U0001f3ff", "pregnant man: Dark Skin Tone", []string{"pregnant_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac3\U0001f3fb", "pregnant man: Light Skin Tone", []string{"pregnant_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac3\U0001f3fe", "pregnant man: Medium-Dark Skin Tone", []string{"pregnant_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac3\U0001f3fc", "pregnant man: Medium-Light Skin Tone", []string{"pregnant_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac3\U0001f3fd", "pregnant man: Medium Skin Tone", []string{"pregnant_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fac4", "pregnant person", []string{"pregnant_person"}, "14.0", true},
+ {"\U0001fac4\U0001f3ff", "pregnant person: Dark Skin Tone", []string{"pregnant_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac4\U0001f3fb", "pregnant person: Light Skin Tone", []string{"pregnant_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac4\U0001f3fe", "pregnant person: Medium-Dark Skin Tone", []string{"pregnant_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001fac4\U0001f3fc", "pregnant person: Medium-Light Skin Tone", []string{"pregnant_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001fac4\U0001f3fd", "pregnant person: Medium Skin Tone", []string{"pregnant_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f930", "pregnant woman", []string{"pregnant_woman"}, "9.0", true},
+ {"\U0001f930\U0001f3ff", "pregnant woman: Dark Skin Tone", []string{"pregnant_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f930\U0001f3fb", "pregnant woman: Light Skin Tone", []string{"pregnant_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f930\U0001f3fe", "pregnant woman: Medium-Dark Skin Tone", []string{"pregnant_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f930\U0001f3fc", "pregnant woman: Medium-Light Skin Tone", []string{"pregnant_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f930\U0001f3fd", "pregnant woman: Medium Skin Tone", []string{"pregnant_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f968", "pretzel", []string{"pretzel"}, "11.0", false},
+ {"\u23ee\ufe0f", "last track button", []string{"previous_track_button"}, "6.0", false},
+ {"\U0001f934", "prince", []string{"prince"}, "9.0", true},
+ {"\U0001f934\U0001f3ff", "prince: Dark Skin Tone", []string{"prince_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f934\U0001f3fb", "prince: Light Skin Tone", []string{"prince_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f934\U0001f3fe", "prince: Medium-Dark Skin Tone", []string{"prince_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f934\U0001f3fc", "prince: Medium-Light Skin Tone", []string{"prince_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f934\U0001f3fd", "prince: Medium Skin Tone", []string{"prince_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f478", "princess", []string{"princess"}, "6.0", true},
+ {"\U0001f478\U0001f3ff", "princess: Dark Skin Tone", []string{"princess_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f478\U0001f3fb", "princess: Light Skin Tone", []string{"princess_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f478\U0001f3fe", "princess: Medium-Dark Skin Tone", []string{"princess_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f478\U0001f3fc", "princess: Medium-Light Skin Tone", []string{"princess_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f478\U0001f3fd", "princess: Medium Skin Tone", []string{"princess_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f5a8\ufe0f", "printer", []string{"printer"}, "7.0", false},
+ {"\U0001f9af", "white cane", []string{"probing_cane"}, "12.0", false},
+ {"\U0001f1f5\U0001f1f7", "flag: Puerto Rico", []string{"puerto_rico"}, "6.0", false},
+ {"\U0001f7e3", "purple circle", []string{"purple_circle"}, "12.0", false},
+ {"\U0001f49c", "purple heart", []string{"purple_heart"}, "6.0", false},
+ {"\U0001f7ea", "purple square", []string{"purple_square"}, "12.0", false},
+ {"\U0001f45b", "purse", []string{"purse"}, "6.0", false},
+ {"\U0001f4cc", "pushpin", []string{"pushpin"}, "6.0", false},
+ {"\U0001f6ae", "litter in bin sign", []string{"put_litter_in_its_place"}, "6.0", false},
+ {"\U0001f1f6\U0001f1e6", "flag: Qatar", []string{"qatar"}, "6.0", false},
+ {"\u2753", "red question mark", []string{"question"}, "6.0", false},
+ {"\U0001f430", "rabbit face", []string{"rabbit"}, "6.0", false},
+ {"\U0001f407", "rabbit", []string{"rabbit2"}, "6.0", false},
+ {"\U0001f99d", "raccoon", []string{"raccoon"}, "11.0", false},
+ {"\U0001f40e", "horse", []string{"racehorse"}, "6.0", false},
+ {"\U0001f3ce\ufe0f", "racing car", []string{"racing_car"}, "7.0", false},
+ {"\U0001f4fb", "radio", []string{"radio"}, "6.0", false},
+ {"\U0001f518", "radio button", []string{"radio_button"}, "6.0", false},
+ {"\u2622\ufe0f", "radioactive", []string{"radioactive"}, "", false},
+ {"\U0001f621", "enraged face", []string{"rage", "pout"}, "6.0", false},
+ {"\U0001f683", "railway car", []string{"railway_car"}, "6.0", false},
+ {"\U0001f6e4\ufe0f", "railway track", []string{"railway_track"}, "7.0", false},
+ {"\U0001f308", "rainbow", []string{"rainbow"}, "6.0", false},
+ {"\U0001f3f3\ufe0f\u200d\U0001f308", "rainbow flag", []string{"rainbow_flag"}, "6.0", false},
+ {"\U0001f91a", "raised back of hand", []string{"raised_back_of_hand"}, "9.0", true},
+ {"\U0001f91a\U0001f3ff", "raised back of hand: Dark Skin Tone", []string{"raised_back_of_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91a\U0001f3fb", "raised back of hand: Light Skin Tone", []string{"raised_back_of_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91a\U0001f3fe", "raised back of hand: Medium-Dark Skin Tone", []string{"raised_back_of_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f91a\U0001f3fc", "raised back of hand: Medium-Light Skin Tone", []string{"raised_back_of_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f91a\U0001f3fd", "raised back of hand: Medium Skin Tone", []string{"raised_back_of_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f928", "face with raised eyebrow", []string{"raised_eyebrow"}, "11.0", false},
+ {"\U0001f590\ufe0f", "hand with fingers splayed", []string{"raised_hand_with_fingers_splayed"}, "7.0", true},
+ {"\U0001f590\U0001f3ff\ufe0f", "hand with fingers splayed: Dark Skin Tone", []string{"raised_hand_with_fingers_splayed_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f590\U0001f3fb\ufe0f", "hand with fingers splayed: Light Skin Tone", []string{"raised_hand_with_fingers_splayed_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f590\U0001f3fe\ufe0f", "hand with fingers splayed: Medium-Dark Skin Tone", []string{"raised_hand_with_fingers_splayed_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f590\U0001f3fc\ufe0f", "hand with fingers splayed: Medium-Light Skin Tone", []string{"raised_hand_with_fingers_splayed_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f590\U0001f3fd\ufe0f", "hand with fingers splayed: Medium Skin Tone", []string{"raised_hand_with_fingers_splayed_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64c", "raising hands", []string{"raised_hands"}, "6.0", true},
+ {"\U0001f64c\U0001f3ff", "raising hands: Dark Skin Tone", []string{"raised_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64c\U0001f3fb", "raising hands: Light Skin Tone", []string{"raised_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64c\U0001f3fe", "raising hands: Medium-Dark Skin Tone", []string{"raised_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64c\U0001f3fc", "raising hands: Medium-Light Skin Tone", []string{"raised_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64c\U0001f3fd", "raising hands: Medium Skin Tone", []string{"raised_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b", "person raising hand", []string{"raising_hand"}, "6.0", true},
+ {"\U0001f64b\U0001f3ff", "person raising hand: Dark Skin Tone", []string{"raising_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fb", "person raising hand: Light Skin Tone", []string{"raising_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fe", "person raising hand: Medium-Dark Skin Tone", []string{"raising_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fc", "person raising hand: Medium-Light Skin Tone", []string{"raising_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fd", "person raising hand: Medium Skin Tone", []string{"raising_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\u200d\u2642\ufe0f", "man raising hand", []string{"raising_hand_man"}, "6.0", true},
+ {"\U0001f64b\U0001f3ff\u200d\u2642\ufe0f", "man raising hand: Dark Skin Tone", []string{"raising_hand_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fb\u200d\u2642\ufe0f", "man raising hand: Light Skin Tone", []string{"raising_hand_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fe\u200d\u2642\ufe0f", "man raising hand: Medium-Dark Skin Tone", []string{"raising_hand_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fc\u200d\u2642\ufe0f", "man raising hand: Medium-Light Skin Tone", []string{"raising_hand_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fd\u200d\u2642\ufe0f", "man raising hand: Medium Skin Tone", []string{"raising_hand_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\u200d\u2640\ufe0f", "woman raising hand", []string{"raising_hand_woman"}, "11.0", true},
+ {"\U0001f64b\U0001f3ff\u200d\u2640\ufe0f", "woman raising hand: Dark Skin Tone", []string{"raising_hand_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fb\u200d\u2640\ufe0f", "woman raising hand: Light Skin Tone", []string{"raising_hand_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fe\u200d\u2640\ufe0f", "woman raising hand: Medium-Dark Skin Tone", []string{"raising_hand_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fc\u200d\u2640\ufe0f", "woman raising hand: Medium-Light Skin Tone", []string{"raising_hand_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f64b\U0001f3fd\u200d\u2640\ufe0f", "woman raising hand: Medium Skin Tone", []string{"raising_hand_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f40f", "ram", []string{"ram"}, "6.0", false},
+ {"\U0001f35c", "steaming bowl", []string{"ramen"}, "6.0", false},
+ {"\U0001f400", "rat", []string{"rat"}, "6.0", false},
+ {"\U0001fa92", "razor", []string{"razor"}, "12.0", false},
+ {"\U0001f9fe", "receipt", []string{"receipt"}, "11.0", false},
+ {"\u23fa\ufe0f", "record button", []string{"record_button"}, "7.0", false},
+ {"\u267b\ufe0f", "recycling symbol", []string{"recycle"}, "3.2", false},
+ {"\U0001f534", "red circle", []string{"red_circle"}, "6.0", false},
+ {"\U0001f9e7", "red envelope", []string{"red_envelope"}, "11.0", false},
+ {"\U0001f468\u200d\U0001f9b0", "man: red hair", []string{"red_haired_man"}, "11.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9b0", "man: red hair: Dark Skin Tone", []string{"red_haired_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9b0", "man: red hair: Light Skin Tone", []string{"red_haired_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9b0", "man: red hair: Medium-Dark Skin Tone", []string{"red_haired_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9b0", "man: red hair: Medium-Light Skin Tone", []string{"red_haired_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9b0", "man: red hair: Medium Skin Tone", []string{"red_haired_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9b0", "woman: red hair", []string{"red_haired_woman"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9b0", "woman: red hair: Dark Skin Tone", []string{"red_haired_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9b0", "woman: red hair: Light Skin Tone", []string{"red_haired_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9b0", "woman: red hair: Medium-Dark Skin Tone", []string{"red_haired_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9b0", "woman: red hair: Medium-Light Skin Tone", []string{"red_haired_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9b0", "woman: red hair: Medium Skin Tone", []string{"red_haired_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f7e5", "red square", []string{"red_square"}, "12.0", false},
+ {"\u00ae\ufe0f", "registered", []string{"registered"}, "", false},
+ {"\u263a\ufe0f", "smiling face", []string{"relaxed"}, "", false},
+ {"\U0001f60c", "relieved face", []string{"relieved"}, "6.0", false},
+ {"\U0001f397\ufe0f", "reminder ribbon", []string{"reminder_ribbon"}, "7.0", false},
+ {"\U0001f501", "repeat button", []string{"repeat"}, "6.0", false},
+ {"\U0001f502", "repeat single button", []string{"repeat_one"}, "6.0", false},
+ {"\u26d1\ufe0f", "rescue worker’s helmet", []string{"rescue_worker_helmet"}, "5.2", false},
+ {"\U0001f6bb", "restroom", []string{"restroom"}, "6.0", false},
+ {"\U0001f1f7\U0001f1ea", "flag: Réunion", []string{"reunion"}, "6.0", false},
+ {"\U0001f49e", "revolving hearts", []string{"revolving_hearts"}, "6.0", false},
+ {"\u23ea", "fast reverse button", []string{"rewind"}, "6.0", false},
+ {"\U0001f98f", "rhinoceros", []string{"rhinoceros"}, "9.0", false},
+ {"\U0001f380", "ribbon", []string{"ribbon"}, "6.0", false},
+ {"\U0001f35a", "cooked rice", []string{"rice"}, "6.0", false},
+ {"\U0001f359", "rice ball", []string{"rice_ball"}, "6.0", false},
+ {"\U0001f358", "rice cracker", []string{"rice_cracker"}, "6.0", false},
+ {"\U0001f391", "moon viewing ceremony", []string{"rice_scene"}, "6.0", false},
+ {"\U0001f5ef\ufe0f", "right anger bubble", []string{"right_anger_bubble"}, "7.0", false},
+ {"\U0001faf1", "rightwards hand", []string{"rightwards_hand"}, "14.0", true},
+ {"\U0001faf1\U0001f3ff", "rightwards hand: Dark Skin Tone", []string{"rightwards_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf1\U0001f3fb", "rightwards hand: Light Skin Tone", []string{"rightwards_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf1\U0001f3fe", "rightwards hand: Medium-Dark Skin Tone", []string{"rightwards_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf1\U0001f3fc", "rightwards hand: Medium-Light Skin Tone", []string{"rightwards_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf1\U0001f3fd", "rightwards hand: Medium Skin Tone", []string{"rightwards_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001faf8", "rightwards pushing hand", []string{"rightwards_pushing_hand"}, "15.0", true},
+ {"\U0001faf8\U0001f3ff", "rightwards pushing hand: Dark Skin Tone", []string{"rightwards_pushing_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf8\U0001f3fb", "rightwards pushing hand: Light Skin Tone", []string{"rightwards_pushing_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf8\U0001f3fe", "rightwards pushing hand: Medium-Dark Skin Tone", []string{"rightwards_pushing_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001faf8\U0001f3fc", "rightwards pushing hand: Medium-Light Skin Tone", []string{"rightwards_pushing_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001faf8\U0001f3fd", "rightwards pushing hand: Medium Skin Tone", []string{"rightwards_pushing_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f48d", "ring", []string{"ring"}, "6.0", false},
+ {"\U0001f6df", "ring buoy", []string{"ring_buoy"}, "14.0", false},
+ {"\U0001fa90", "ringed planet", []string{"ringed_planet"}, "12.0", false},
+ {"\U0001f916", "robot", []string{"robot"}, "8.0", false},
+ {"\U0001faa8", "rock", []string{"rock"}, "13.0", false},
+ {"\U0001f680", "rocket", []string{"rocket"}, "6.0", false},
+ {"\U0001f923", "rolling on the floor laughing", []string{"rofl"}, "9.0", false},
+ {"\U0001f644", "face with rolling eyes", []string{"roll_eyes"}, "8.0", false},
+ {"\U0001f9fb", "roll of paper", []string{"roll_of_paper"}, "11.0", false},
+ {"\U0001f3a2", "roller coaster", []string{"roller_coaster"}, "6.0", false},
+ {"\U0001f6fc", "roller skate", []string{"roller_skate"}, "13.0", false},
+ {"\U0001f1f7\U0001f1f4", "flag: Romania", []string{"romania"}, "6.0", false},
+ {"\U0001f413", "rooster", []string{"rooster"}, "6.0", false},
+ {"\U0001f339", "rose", []string{"rose"}, "6.0", false},
+ {"\U0001f3f5\ufe0f", "rosette", []string{"rosette"}, "7.0", false},
+ {"\U0001f6a8", "police car light", []string{"rotating_light"}, "6.0", false},
+ {"\U0001f4cd", "round pushpin", []string{"round_pushpin"}, "6.0", false},
+ {"\U0001f6a3", "person rowing boat", []string{"rowboat"}, "6.0", true},
+ {"\U0001f6a3\U0001f3ff", "person rowing boat: Dark Skin Tone", []string{"rowboat_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fb", "person rowing boat: Light Skin Tone", []string{"rowboat_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fe", "person rowing boat: Medium-Dark Skin Tone", []string{"rowboat_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fc", "person rowing boat: Medium-Light Skin Tone", []string{"rowboat_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fd", "person rowing boat: Medium Skin Tone", []string{"rowboat_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\u200d\u2642\ufe0f", "man rowing boat", []string{"rowing_man"}, "11.0", true},
+ {"\U0001f6a3\U0001f3ff\u200d\u2642\ufe0f", "man rowing boat: Dark Skin Tone", []string{"rowing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fb\u200d\u2642\ufe0f", "man rowing boat: Light Skin Tone", []string{"rowing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fe\u200d\u2642\ufe0f", "man rowing boat: Medium-Dark Skin Tone", []string{"rowing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fc\u200d\u2642\ufe0f", "man rowing boat: Medium-Light Skin Tone", []string{"rowing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fd\u200d\u2642\ufe0f", "man rowing boat: Medium Skin Tone", []string{"rowing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\u200d\u2640\ufe0f", "woman rowing boat", []string{"rowing_woman"}, "6.0", true},
+ {"\U0001f6a3\U0001f3ff\u200d\u2640\ufe0f", "woman rowing boat: Dark Skin Tone", []string{"rowing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fb\u200d\u2640\ufe0f", "woman rowing boat: Light Skin Tone", []string{"rowing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fe\u200d\u2640\ufe0f", "woman rowing boat: Medium-Dark Skin Tone", []string{"rowing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fc\u200d\u2640\ufe0f", "woman rowing boat: Medium-Light Skin Tone", []string{"rowing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6a3\U0001f3fd\u200d\u2640\ufe0f", "woman rowing boat: Medium Skin Tone", []string{"rowing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f7\U0001f1fa", "flag: Russia", []string{"ru"}, "6.0", false},
+ {"\U0001f3c9", "rugby football", []string{"rugby_football"}, "6.0", false},
+ {"\U0001f3c3", "person running", []string{"runner", "running"}, "6.0", true},
+ {"\U0001f3c3\U0001f3ff", "person running: Dark Skin Tone", []string{"runner_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fb", "person running: Light Skin Tone", []string{"runner_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fe", "person running: Medium-Dark Skin Tone", []string{"runner_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fc", "person running: Medium-Light Skin Tone", []string{"runner_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fd", "person running: Medium Skin Tone", []string{"runner_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\u200d\u2642\ufe0f", "man running", []string{"running_man"}, "11.0", true},
+ {"\U0001f3c3\U0001f3ff\u200d\u2642\ufe0f", "man running: Dark Skin Tone", []string{"running_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fb\u200d\u2642\ufe0f", "man running: Light Skin Tone", []string{"running_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fe\u200d\u2642\ufe0f", "man running: Medium-Dark Skin Tone", []string{"running_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fc\u200d\u2642\ufe0f", "man running: Medium-Light Skin Tone", []string{"running_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fd\u200d\u2642\ufe0f", "man running: Medium Skin Tone", []string{"running_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3bd", "running shirt", []string{"running_shirt_with_sash"}, "6.0", false},
+ {"\U0001f3c3\u200d\u2640\ufe0f", "woman running", []string{"running_woman"}, "6.0", true},
+ {"\U0001f3c3\U0001f3ff\u200d\u2640\ufe0f", "woman running: Dark Skin Tone", []string{"running_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fb\u200d\u2640\ufe0f", "woman running: Light Skin Tone", []string{"running_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fe\u200d\u2640\ufe0f", "woman running: Medium-Dark Skin Tone", []string{"running_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fc\u200d\u2640\ufe0f", "woman running: Medium-Light Skin Tone", []string{"running_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c3\U0001f3fd\u200d\u2640\ufe0f", "woman running: Medium Skin Tone", []string{"running_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f7\U0001f1fc", "flag: Rwanda", []string{"rwanda"}, "6.0", false},
+ {"\U0001f202\ufe0f", "Japanese “service charge†button", []string{"sa"}, "6.0", false},
+ {"\U0001f9f7", "safety pin", []string{"safety_pin"}, "11.0", false},
+ {"\U0001f9ba", "safety vest", []string{"safety_vest"}, "12.0", false},
+ {"\u2650", "Sagittarius", []string{"sagittarius"}, "", false},
+ {"\U0001f376", "sake", []string{"sake"}, "6.0", false},
+ {"\U0001f9c2", "salt", []string{"salt"}, "11.0", false},
+ {"\U0001fae1", "saluting face", []string{"saluting_face"}, "14.0", false},
+ {"\U0001f1fc\U0001f1f8", "flag: Samoa", []string{"samoa"}, "6.0", false},
+ {"\U0001f1f8\U0001f1f2", "flag: San Marino", []string{"san_marino"}, "6.0", false},
+ {"\U0001f461", "woman’s sandal", []string{"sandal"}, "6.0", false},
+ {"\U0001f96a", "sandwich", []string{"sandwich"}, "11.0", false},
+ {"\U0001f385", "Santa Claus", []string{"santa"}, "6.0", true},
+ {"\U0001f385\U0001f3ff", "Santa Claus: Dark Skin Tone", []string{"santa_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f385\U0001f3fb", "Santa Claus: Light Skin Tone", []string{"santa_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f385\U0001f3fe", "Santa Claus: Medium-Dark Skin Tone", []string{"santa_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f385\U0001f3fc", "Santa Claus: Medium-Light Skin Tone", []string{"santa_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f385\U0001f3fd", "Santa Claus: Medium Skin Tone", []string{"santa_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f8\U0001f1f9", "flag: São Tomé & Príncipe", []string{"sao_tome_principe"}, "6.0", false},
+ {"\U0001f97b", "sari", []string{"sari"}, "12.0", false},
+ {"\U0001f4e1", "satellite antenna", []string{"satellite"}, "6.0", false},
+ {"\U0001f1f8\U0001f1e6", "flag: Saudi Arabia", []string{"saudi_arabia"}, "6.0", false},
+ {"\U0001f9d6\u200d\u2642\ufe0f", "man in steamy room", []string{"sauna_man"}, "11.0", true},
+ {"\U0001f9d6\U0001f3ff\u200d\u2642\ufe0f", "man in steamy room: Dark Skin Tone", []string{"sauna_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fb\u200d\u2642\ufe0f", "man in steamy room: Light Skin Tone", []string{"sauna_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fe\u200d\u2642\ufe0f", "man in steamy room: Medium-Dark Skin Tone", []string{"sauna_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fc\u200d\u2642\ufe0f", "man in steamy room: Medium-Light Skin Tone", []string{"sauna_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fd\u200d\u2642\ufe0f", "man in steamy room: Medium Skin Tone", []string{"sauna_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6", "person in steamy room", []string{"sauna_person"}, "11.0", true},
+ {"\U0001f9d6\U0001f3ff", "person in steamy room: Dark Skin Tone", []string{"sauna_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fb", "person in steamy room: Light Skin Tone", []string{"sauna_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fe", "person in steamy room: Medium-Dark Skin Tone", []string{"sauna_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fc", "person in steamy room: Medium-Light Skin Tone", []string{"sauna_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fd", "person in steamy room: Medium Skin Tone", []string{"sauna_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\u200d\u2640\ufe0f", "woman in steamy room", []string{"sauna_woman"}, "11.0", true},
+ {"\U0001f9d6\U0001f3ff\u200d\u2640\ufe0f", "woman in steamy room: Dark Skin Tone", []string{"sauna_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fb\u200d\u2640\ufe0f", "woman in steamy room: Light Skin Tone", []string{"sauna_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fe\u200d\u2640\ufe0f", "woman in steamy room: Medium-Dark Skin Tone", []string{"sauna_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fc\u200d\u2640\ufe0f", "woman in steamy room: Medium-Light Skin Tone", []string{"sauna_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d6\U0001f3fd\u200d\u2640\ufe0f", "woman in steamy room: Medium Skin Tone", []string{"sauna_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f995", "sauropod", []string{"sauropod"}, "11.0", false},
+ {"\U0001f3b7", "saxophone", []string{"saxophone"}, "6.0", false},
+ {"\U0001f9e3", "scarf", []string{"scarf"}, "11.0", false},
+ {"\U0001f3eb", "school", []string{"school"}, "6.0", false},
+ {"\U0001f392", "backpack", []string{"school_satchel"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f52c", "scientist", []string{"scientist"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f52c", "scientist: Dark Skin Tone", []string{"scientist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f52c", "scientist: Light Skin Tone", []string{"scientist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f52c", "scientist: Medium-Dark Skin Tone", []string{"scientist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f52c", "scientist: Medium-Light Skin Tone", []string{"scientist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f52c", "scientist: Medium Skin Tone", []string{"scientist_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2702\ufe0f", "scissors", []string{"scissors"}, "", false},
+ {"\U0001f982", "scorpion", []string{"scorpion"}, "8.0", false},
+ {"\u264f", "Scorpio", []string{"scorpius"}, "", false},
+ {"\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", "flag: Scotland", []string{"scotland"}, "11.0", false},
+ {"\U0001f631", "face screaming in fear", []string{"scream"}, "6.0", false},
+ {"\U0001f640", "weary cat", []string{"scream_cat"}, "6.0", false},
+ {"\U0001fa9b", "screwdriver", []string{"screwdriver"}, "13.0", false},
+ {"\U0001f4dc", "scroll", []string{"scroll"}, "6.0", false},
+ {"\U0001f9ad", "seal", []string{"seal"}, "13.0", false},
+ {"\U0001f4ba", "seat", []string{"seat"}, "6.0", false},
+ {"\u3299\ufe0f", "Japanese “secret†button", []string{"secret"}, "", false},
+ {"\U0001f648", "see-no-evil monkey", []string{"see_no_evil"}, "6.0", false},
+ {"\U0001f331", "seedling", []string{"seedling"}, "6.0", false},
+ {"\U0001f933", "selfie", []string{"selfie"}, "9.0", true},
+ {"\U0001f933\U0001f3ff", "selfie: Dark Skin Tone", []string{"selfie_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f933\U0001f3fb", "selfie: Light Skin Tone", []string{"selfie_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f933\U0001f3fe", "selfie: Medium-Dark Skin Tone", []string{"selfie_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f933\U0001f3fc", "selfie: Medium-Light Skin Tone", []string{"selfie_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f933\U0001f3fd", "selfie: Medium Skin Tone", []string{"selfie_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f8\U0001f1f3", "flag: Senegal", []string{"senegal"}, "6.0", false},
+ {"\U0001f1f7\U0001f1f8", "flag: Serbia", []string{"serbia"}, "6.0", false},
+ {"\U0001f415\u200d\U0001f9ba", "service dog", []string{"service_dog"}, "12.0", false},
+ {"7\ufe0f\u20e3", "keycap: 7", []string{"seven"}, "", false},
+ {"\U0001faa1", "sewing needle", []string{"sewing_needle"}, "13.0", false},
+ {"\U0001f1f8\U0001f1e8", "flag: Seychelles", []string{"seychelles"}, "6.0", false},
+ {"\U0001fae8", "shaking face", []string{"shaking_face"}, "15.0", false},
+ {"\U0001f958", "shallow pan of food", []string{"shallow_pan_of_food"}, "", false},
+ {"\u2618\ufe0f", "shamrock", []string{"shamrock"}, "4.1", false},
+ {"\U0001f988", "shark", []string{"shark"}, "9.0", false},
+ {"\U0001f367", "shaved ice", []string{"shaved_ice"}, "6.0", false},
+ {"\U0001f411", "ewe", []string{"sheep"}, "6.0", false},
+ {"\U0001f41a", "spiral shell", []string{"shell"}, "6.0", false},
+ {"\U0001f6e1\ufe0f", "shield", []string{"shield"}, "7.0", false},
+ {"\u26e9\ufe0f", "shinto shrine", []string{"shinto_shrine"}, "5.2", false},
+ {"\U0001f6a2", "ship", []string{"ship"}, "6.0", false},
+ {"\U0001f455", "t-shirt", []string{"shirt", "tshirt"}, "6.0", false},
+ {"\U0001f6cd\ufe0f", "shopping bags", []string{"shopping"}, "7.0", false},
+ {"\U0001f6d2", "shopping cart", []string{"shopping_cart"}, "9.0", false},
+ {"\U0001fa73", "shorts", []string{"shorts"}, "12.0", false},
+ {"\U0001f6bf", "shower", []string{"shower"}, "6.0", false},
+ {"\U0001f990", "shrimp", []string{"shrimp"}, "9.0", false},
+ {"\U0001f937", "person shrugging", []string{"shrug"}, "11.0", true},
+ {"\U0001f937\U0001f3ff", "person shrugging: Dark Skin Tone", []string{"shrug_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fb", "person shrugging: Light Skin Tone", []string{"shrug_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fe", "person shrugging: Medium-Dark Skin Tone", []string{"shrug_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fc", "person shrugging: Medium-Light Skin Tone", []string{"shrug_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fd", "person shrugging: Medium Skin Tone", []string{"shrug_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f92b", "shushing face", []string{"shushing_face"}, "11.0", false},
+ {"\U0001f1f8\U0001f1f1", "flag: Sierra Leone", []string{"sierra_leone"}, "6.0", false},
+ {"\U0001f4f6", "antenna bars", []string{"signal_strength"}, "6.0", false},
+ {"\U0001f1f8\U0001f1ec", "flag: Singapore", []string{"singapore"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f3a4", "singer", []string{"singer"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f3a4", "singer: Dark Skin Tone", []string{"singer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f3a4", "singer: Light Skin Tone", []string{"singer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f3a4", "singer: Medium-Dark Skin Tone", []string{"singer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f3a4", "singer: Medium-Light Skin Tone", []string{"singer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f3a4", "singer: Medium Skin Tone", []string{"singer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f8\U0001f1fd", "flag: Sint Maarten", []string{"sint_maarten"}, "6.0", false},
+ {"6\ufe0f\u20e3", "keycap: 6", []string{"six"}, "", false},
+ {"\U0001f52f", "dotted six-pointed star", []string{"six_pointed_star"}, "6.0", false},
+ {"\U0001f6f9", "skateboard", []string{"skateboard"}, "11.0", false},
+ {"\U0001f3bf", "skis", []string{"ski"}, "6.0", false},
+ {"\u26f7\ufe0f", "skier", []string{"skier"}, "5.2", false},
+ {"\U0001f480", "skull", []string{"skull"}, "6.0", false},
+ {"\u2620\ufe0f", "skull and crossbones", []string{"skull_and_crossbones"}, "", false},
+ {"\U0001f9a8", "skunk", []string{"skunk"}, "12.0", false},
+ {"\U0001f6f7", "sled", []string{"sled"}, "11.0", false},
+ {"\U0001f634", "sleeping face", []string{"sleeping"}, "6.1", false},
+ {"\U0001f6cc", "person in bed", []string{"sleeping_bed"}, "7.0", true},
+ {"\U0001f6cc\U0001f3ff", "person in bed: Dark Skin Tone", []string{"sleeping_bed_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6cc\U0001f3fb", "person in bed: Light Skin Tone", []string{"sleeping_bed_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6cc\U0001f3fe", "person in bed: Medium-Dark Skin Tone", []string{"sleeping_bed_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6cc\U0001f3fc", "person in bed: Medium-Light Skin Tone", []string{"sleeping_bed_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6cc\U0001f3fd", "person in bed: Medium Skin Tone", []string{"sleeping_bed_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f62a", "sleepy face", []string{"sleepy"}, "6.0", false},
+ {"\U0001f641", "slightly frowning face", []string{"slightly_frowning_face"}, "7.0", false},
+ {"\U0001f642", "slightly smiling face", []string{"slightly_smiling_face"}, "7.0", false},
+ {"\U0001f3b0", "slot machine", []string{"slot_machine"}, "6.0", false},
+ {"\U0001f9a5", "sloth", []string{"sloth"}, "12.0", false},
+ {"\U0001f1f8\U0001f1f0", "flag: Slovakia", []string{"slovakia"}, "6.0", false},
+ {"\U0001f1f8\U0001f1ee", "flag: Slovenia", []string{"slovenia"}, "6.0", false},
+ {"\U0001f6e9\ufe0f", "small airplane", []string{"small_airplane"}, "7.0", false},
+ {"\U0001f539", "small blue diamond", []string{"small_blue_diamond"}, "6.0", false},
+ {"\U0001f538", "small orange diamond", []string{"small_orange_diamond"}, "6.0", false},
+ {"\U0001f53a", "red triangle pointed up", []string{"small_red_triangle"}, "6.0", false},
+ {"\U0001f53b", "red triangle pointed down", []string{"small_red_triangle_down"}, "6.0", false},
+ {"\U0001f604", "grinning face with smiling eyes", []string{"smile"}, "6.0", false},
+ {"\U0001f638", "grinning cat with smiling eyes", []string{"smile_cat"}, "6.0", false},
+ {"\U0001f603", "grinning face with big eyes", []string{"smiley"}, "6.0", false},
+ {"\U0001f63a", "grinning cat", []string{"smiley_cat"}, "6.0", false},
+ {"\U0001f972", "smiling face with tear", []string{"smiling_face_with_tear"}, "13.0", false},
+ {"\U0001f970", "smiling face with hearts", []string{"smiling_face_with_three_hearts"}, "11.0", false},
+ {"\U0001f608", "smiling face with horns", []string{"smiling_imp"}, "6.0", false},
+ {"\U0001f60f", "smirking face", []string{"smirk"}, "6.0", false},
+ {"\U0001f63c", "cat with wry smile", []string{"smirk_cat"}, "6.0", false},
+ {"\U0001f6ac", "cigarette", []string{"smoking"}, "6.0", false},
+ {"\U0001f40c", "snail", []string{"snail"}, "6.0", false},
+ {"\U0001f40d", "snake", []string{"snake"}, "6.0", false},
+ {"\U0001f927", "sneezing face", []string{"sneezing_face"}, "9.0", false},
+ {"\U0001f3c2", "snowboarder", []string{"snowboarder"}, "6.0", true},
+ {"\U0001f3c2\U0001f3ff", "snowboarder: Dark Skin Tone", []string{"snowboarder_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c2\U0001f3fb", "snowboarder: Light Skin Tone", []string{"snowboarder_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c2\U0001f3fe", "snowboarder: Medium-Dark Skin Tone", []string{"snowboarder_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c2\U0001f3fc", "snowboarder: Medium-Light Skin Tone", []string{"snowboarder_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c2\U0001f3fd", "snowboarder: Medium Skin Tone", []string{"snowboarder_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2744\ufe0f", "snowflake", []string{"snowflake"}, "", false},
+ {"\u26c4", "snowman without snow", []string{"snowman"}, "5.2", false},
+ {"\u2603\ufe0f", "snowman", []string{"snowman_with_snow"}, "", false},
+ {"\U0001f9fc", "soap", []string{"soap"}, "11.0", false},
+ {"\U0001f62d", "loudly crying face", []string{"sob"}, "6.0", false},
+ {"\u26bd", "soccer ball", []string{"soccer"}, "5.2", false},
+ {"\U0001f9e6", "socks", []string{"socks"}, "11.0", false},
+ {"\U0001f94e", "softball", []string{"softball"}, "11.0", false},
+ {"\U0001f1f8\U0001f1e7", "flag: Solomon Islands", []string{"solomon_islands"}, "6.0", false},
+ {"\U0001f1f8\U0001f1f4", "flag: Somalia", []string{"somalia"}, "6.0", false},
+ {"\U0001f51c", "SOON arrow", []string{"soon"}, "6.0", false},
+ {"\U0001f198", "SOS button", []string{"sos"}, "6.0", false},
+ {"\U0001f509", "speaker medium volume", []string{"sound"}, "6.0", false},
+ {"\U0001f1ff\U0001f1e6", "flag: South Africa", []string{"south_africa"}, "6.0", false},
+ {"\U0001f1ec\U0001f1f8", "flag: South Georgia & South Sandwich Islands", []string{"south_georgia_south_sandwich_islands"}, "6.0", false},
+ {"\U0001f1f8\U0001f1f8", "flag: South Sudan", []string{"south_sudan"}, "6.0", false},
+ {"\U0001f47e", "alien monster", []string{"space_invader"}, "6.0", false},
+ {"\u2660\ufe0f", "spade suit", []string{"spades"}, "", false},
+ {"\U0001f35d", "spaghetti", []string{"spaghetti"}, "6.0", false},
+ {"\u2747\ufe0f", "sparkle", []string{"sparkle"}, "", false},
+ {"\U0001f387", "sparkler", []string{"sparkler"}, "6.0", false},
+ {"\u2728", "sparkles", []string{"sparkles"}, "6.0", false},
+ {"\U0001f496", "sparkling heart", []string{"sparkling_heart"}, "6.0", false},
+ {"\U0001f64a", "speak-no-evil monkey", []string{"speak_no_evil"}, "6.0", false},
+ {"\U0001f508", "speaker low volume", []string{"speaker"}, "6.0", false},
+ {"\U0001f5e3\ufe0f", "speaking head", []string{"speaking_head"}, "7.0", false},
+ {"\U0001f4ac", "speech balloon", []string{"speech_balloon"}, "6.0", false},
+ {"\U0001f6a4", "speedboat", []string{"speedboat"}, "6.0", false},
+ {"\U0001f577\ufe0f", "spider", []string{"spider"}, "7.0", false},
+ {"\U0001f578\ufe0f", "spider web", []string{"spider_web"}, "7.0", false},
+ {"\U0001f5d3\ufe0f", "spiral calendar", []string{"spiral_calendar"}, "7.0", false},
+ {"\U0001f5d2\ufe0f", "spiral notepad", []string{"spiral_notepad"}, "7.0", false},
+ {"\U0001f9fd", "sponge", []string{"sponge"}, "11.0", false},
+ {"\U0001f944", "spoon", []string{"spoon"}, "9.0", false},
+ {"\U0001f991", "squid", []string{"squid"}, "9.0", false},
+ {"\U0001f1f1\U0001f1f0", "flag: Sri Lanka", []string{"sri_lanka"}, "6.0", false},
+ {"\U0001f1e7\U0001f1f1", "flag: St. Barthélemy", []string{"st_barthelemy"}, "6.0", false},
+ {"\U0001f1f8\U0001f1ed", "flag: St. Helena", []string{"st_helena"}, "6.0", false},
+ {"\U0001f1f0\U0001f1f3", "flag: St. Kitts & Nevis", []string{"st_kitts_nevis"}, "6.0", false},
+ {"\U0001f1f1\U0001f1e8", "flag: St. Lucia", []string{"st_lucia"}, "6.0", false},
+ {"\U0001f1f2\U0001f1eb", "flag: St. Martin", []string{"st_martin"}, "11.0", false},
+ {"\U0001f1f5\U0001f1f2", "flag: St. Pierre & Miquelon", []string{"st_pierre_miquelon"}, "6.0", false},
+ {"\U0001f1fb\U0001f1e8", "flag: St. Vincent & Grenadines", []string{"st_vincent_grenadines"}, "6.0", false},
+ {"\U0001f3df\ufe0f", "stadium", []string{"stadium"}, "7.0", false},
+ {"\U0001f9cd\u200d\u2642\ufe0f", "man standing", []string{"standing_man"}, "12.0", true},
+ {"\U0001f9cd\U0001f3ff\u200d\u2642\ufe0f", "man standing: Dark Skin Tone", []string{"standing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fb\u200d\u2642\ufe0f", "man standing: Light Skin Tone", []string{"standing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fe\u200d\u2642\ufe0f", "man standing: Medium-Dark Skin Tone", []string{"standing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fc\u200d\u2642\ufe0f", "man standing: Medium-Light Skin Tone", []string{"standing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fd\u200d\u2642\ufe0f", "man standing: Medium Skin Tone", []string{"standing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd", "person standing", []string{"standing_person"}, "12.0", true},
+ {"\U0001f9cd\U0001f3ff", "person standing: Dark Skin Tone", []string{"standing_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fb", "person standing: Light Skin Tone", []string{"standing_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fe", "person standing: Medium-Dark Skin Tone", []string{"standing_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fc", "person standing: Medium-Light Skin Tone", []string{"standing_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fd", "person standing: Medium Skin Tone", []string{"standing_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\u200d\u2640\ufe0f", "woman standing", []string{"standing_woman"}, "12.0", true},
+ {"\U0001f9cd\U0001f3ff\u200d\u2640\ufe0f", "woman standing: Dark Skin Tone", []string{"standing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fb\u200d\u2640\ufe0f", "woman standing: Light Skin Tone", []string{"standing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fe\u200d\u2640\ufe0f", "woman standing: Medium-Dark Skin Tone", []string{"standing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fc\u200d\u2640\ufe0f", "woman standing: Medium-Light Skin Tone", []string{"standing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9cd\U0001f3fd\u200d\u2640\ufe0f", "woman standing: Medium Skin Tone", []string{"standing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\u2b50", "star", []string{"star"}, "5.1", false},
+ {"\U0001f31f", "glowing star", []string{"star2"}, "6.0", false},
+ {"\u262a\ufe0f", "star and crescent", []string{"star_and_crescent"}, "", false},
+ {"\u2721\ufe0f", "star of David", []string{"star_of_david"}, "", false},
+ {"\U0001f929", "star-struck", []string{"star_struck"}, "11.0", false},
+ {"\U0001f320", "shooting star", []string{"stars"}, "6.0", false},
+ {"\U0001f689", "station", []string{"station"}, "6.0", false},
+ {"\U0001f5fd", "Statue of Liberty", []string{"statue_of_liberty"}, "6.0", false},
+ {"\U0001f682", "locomotive", []string{"steam_locomotive"}, "6.0", false},
+ {"\U0001fa7a", "stethoscope", []string{"stethoscope"}, "12.0", false},
+ {"\U0001f372", "pot of food", []string{"stew"}, "6.0", false},
+ {"\u23f9\ufe0f", "stop button", []string{"stop_button"}, "7.0", false},
+ {"\U0001f6d1", "stop sign", []string{"stop_sign"}, "9.0", false},
+ {"\u23f1\ufe0f", "stopwatch", []string{"stopwatch"}, "6.0", false},
+ {"\U0001f4cf", "straight ruler", []string{"straight_ruler"}, "6.0", false},
+ {"\U0001f353", "strawberry", []string{"strawberry"}, "6.0", false},
+ {"\U0001f61b", "face with tongue", []string{"stuck_out_tongue"}, "6.1", false},
+ {"\U0001f61d", "squinting face with tongue", []string{"stuck_out_tongue_closed_eyes"}, "6.0", false},
+ {"\U0001f61c", "winking face with tongue", []string{"stuck_out_tongue_winking_eye"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f393", "student", []string{"student"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f393", "student: Dark Skin Tone", []string{"student_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f393", "student: Light Skin Tone", []string{"student_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f393", "student: Medium-Dark Skin Tone", []string{"student_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f393", "student: Medium-Light Skin Tone", []string{"student_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f393", "student: Medium Skin Tone", []string{"student_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f399\ufe0f", "studio microphone", []string{"studio_microphone"}, "7.0", false},
+ {"\U0001f959", "stuffed flatbread", []string{"stuffed_flatbread"}, "9.0", false},
+ {"\U0001f1f8\U0001f1e9", "flag: Sudan", []string{"sudan"}, "6.0", false},
+ {"\U0001f325\ufe0f", "sun behind large cloud", []string{"sun_behind_large_cloud"}, "7.0", false},
+ {"\U0001f326\ufe0f", "sun behind rain cloud", []string{"sun_behind_rain_cloud"}, "7.0", false},
+ {"\U0001f324\ufe0f", "sun behind small cloud", []string{"sun_behind_small_cloud"}, "7.0", false},
+ {"\U0001f31e", "sun with face", []string{"sun_with_face"}, "6.0", false},
+ {"\U0001f33b", "sunflower", []string{"sunflower"}, "6.0", false},
+ {"\U0001f60e", "smiling face with sunglasses", []string{"sunglasses"}, "6.0", false},
+ {"\u2600\ufe0f", "sun", []string{"sunny"}, "", false},
+ {"\U0001f305", "sunrise", []string{"sunrise"}, "6.0", false},
+ {"\U0001f304", "sunrise over mountains", []string{"sunrise_over_mountains"}, "6.0", false},
+ {"\U0001f9b8", "superhero", []string{"superhero"}, "11.0", true},
+ {"\U0001f9b8\U0001f3ff", "superhero: Dark Skin Tone", []string{"superhero_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fb", "superhero: Light Skin Tone", []string{"superhero_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fe", "superhero: Medium-Dark Skin Tone", []string{"superhero_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fc", "superhero: Medium-Light Skin Tone", []string{"superhero_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fd", "superhero: Medium Skin Tone", []string{"superhero_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\u200d\u2642\ufe0f", "man superhero", []string{"superhero_man"}, "11.0", true},
+ {"\U0001f9b8\U0001f3ff\u200d\u2642\ufe0f", "man superhero: Dark Skin Tone", []string{"superhero_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fb\u200d\u2642\ufe0f", "man superhero: Light Skin Tone", []string{"superhero_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fe\u200d\u2642\ufe0f", "man superhero: Medium-Dark Skin Tone", []string{"superhero_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fc\u200d\u2642\ufe0f", "man superhero: Medium-Light Skin Tone", []string{"superhero_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fd\u200d\u2642\ufe0f", "man superhero: Medium Skin Tone", []string{"superhero_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\u200d\u2640\ufe0f", "woman superhero", []string{"superhero_woman"}, "11.0", true},
+ {"\U0001f9b8\U0001f3ff\u200d\u2640\ufe0f", "woman superhero: Dark Skin Tone", []string{"superhero_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fb\u200d\u2640\ufe0f", "woman superhero: Light Skin Tone", []string{"superhero_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fe\u200d\u2640\ufe0f", "woman superhero: Medium-Dark Skin Tone", []string{"superhero_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fc\u200d\u2640\ufe0f", "woman superhero: Medium-Light Skin Tone", []string{"superhero_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b8\U0001f3fd\u200d\u2640\ufe0f", "woman superhero: Medium Skin Tone", []string{"superhero_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9", "supervillain", []string{"supervillain"}, "11.0", true},
+ {"\U0001f9b9\U0001f3ff", "supervillain: Dark Skin Tone", []string{"supervillain_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fb", "supervillain: Light Skin Tone", []string{"supervillain_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fe", "supervillain: Medium-Dark Skin Tone", []string{"supervillain_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fc", "supervillain: Medium-Light Skin Tone", []string{"supervillain_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fd", "supervillain: Medium Skin Tone", []string{"supervillain_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\u200d\u2642\ufe0f", "man supervillain", []string{"supervillain_man"}, "11.0", true},
+ {"\U0001f9b9\U0001f3ff\u200d\u2642\ufe0f", "man supervillain: Dark Skin Tone", []string{"supervillain_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fb\u200d\u2642\ufe0f", "man supervillain: Light Skin Tone", []string{"supervillain_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fe\u200d\u2642\ufe0f", "man supervillain: Medium-Dark Skin Tone", []string{"supervillain_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fc\u200d\u2642\ufe0f", "man supervillain: Medium-Light Skin Tone", []string{"supervillain_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fd\u200d\u2642\ufe0f", "man supervillain: Medium Skin Tone", []string{"supervillain_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\u200d\u2640\ufe0f", "woman supervillain", []string{"supervillain_woman"}, "11.0", true},
+ {"\U0001f9b9\U0001f3ff\u200d\u2640\ufe0f", "woman supervillain: Dark Skin Tone", []string{"supervillain_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fb\u200d\u2640\ufe0f", "woman supervillain: Light Skin Tone", []string{"supervillain_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fe\u200d\u2640\ufe0f", "woman supervillain: Medium-Dark Skin Tone", []string{"supervillain_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fc\u200d\u2640\ufe0f", "woman supervillain: Medium-Light Skin Tone", []string{"supervillain_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9b9\U0001f3fd\u200d\u2640\ufe0f", "woman supervillain: Medium Skin Tone", []string{"supervillain_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4", "person surfing", []string{"surfer"}, "6.0", true},
+ {"\U0001f3c4\U0001f3ff", "person surfing: Dark Skin Tone", []string{"surfer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fb", "person surfing: Light Skin Tone", []string{"surfer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fe", "person surfing: Medium-Dark Skin Tone", []string{"surfer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fc", "person surfing: Medium-Light Skin Tone", []string{"surfer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fd", "person surfing: Medium Skin Tone", []string{"surfer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\u200d\u2642\ufe0f", "man surfing", []string{"surfing_man"}, "11.0", true},
+ {"\U0001f3c4\U0001f3ff\u200d\u2642\ufe0f", "man surfing: Dark Skin Tone", []string{"surfing_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fb\u200d\u2642\ufe0f", "man surfing: Light Skin Tone", []string{"surfing_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fe\u200d\u2642\ufe0f", "man surfing: Medium-Dark Skin Tone", []string{"surfing_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fc\u200d\u2642\ufe0f", "man surfing: Medium-Light Skin Tone", []string{"surfing_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fd\u200d\u2642\ufe0f", "man surfing: Medium Skin Tone", []string{"surfing_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\u200d\u2640\ufe0f", "woman surfing", []string{"surfing_woman"}, "7.0", true},
+ {"\U0001f3c4\U0001f3ff\u200d\u2640\ufe0f", "woman surfing: Dark Skin Tone", []string{"surfing_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fb\u200d\u2640\ufe0f", "woman surfing: Light Skin Tone", []string{"surfing_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fe\u200d\u2640\ufe0f", "woman surfing: Medium-Dark Skin Tone", []string{"surfing_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fc\u200d\u2640\ufe0f", "woman surfing: Medium-Light Skin Tone", []string{"surfing_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3c4\U0001f3fd\u200d\u2640\ufe0f", "woman surfing: Medium Skin Tone", []string{"surfing_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1f8\U0001f1f7", "flag: Suriname", []string{"suriname"}, "6.0", false},
+ {"\U0001f363", "sushi", []string{"sushi"}, "6.0", false},
+ {"\U0001f69f", "suspension railway", []string{"suspension_railway"}, "6.0", false},
+ {"\U0001f1f8\U0001f1ef", "flag: Svalbard & Jan Mayen", []string{"svalbard_jan_mayen"}, "11.0", false},
+ {"\U0001f9a2", "swan", []string{"swan"}, "11.0", false},
+ {"\U0001f1f8\U0001f1ff", "flag: Eswatini", []string{"swaziland"}, "6.0", false},
+ {"\U0001f613", "downcast face with sweat", []string{"sweat"}, "6.0", false},
+ {"\U0001f4a6", "sweat droplets", []string{"sweat_drops"}, "6.0", false},
+ {"\U0001f605", "grinning face with sweat", []string{"sweat_smile"}, "6.0", false},
+ {"\U0001f1f8\U0001f1ea", "flag: Sweden", []string{"sweden"}, "6.0", false},
+ {"\U0001f360", "roasted sweet potato", []string{"sweet_potato"}, "6.0", false},
+ {"\U0001fa72", "briefs", []string{"swim_brief"}, "12.0", false},
+ {"\U0001f3ca", "person swimming", []string{"swimmer"}, "6.0", true},
+ {"\U0001f3ca\U0001f3ff", "person swimming: Dark Skin Tone", []string{"swimmer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fb", "person swimming: Light Skin Tone", []string{"swimmer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fe", "person swimming: Medium-Dark Skin Tone", []string{"swimmer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fc", "person swimming: Medium-Light Skin Tone", []string{"swimmer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fd", "person swimming: Medium Skin Tone", []string{"swimmer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\u200d\u2642\ufe0f", "man swimming", []string{"swimming_man"}, "11.0", true},
+ {"\U0001f3ca\U0001f3ff\u200d\u2642\ufe0f", "man swimming: Dark Skin Tone", []string{"swimming_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fb\u200d\u2642\ufe0f", "man swimming: Light Skin Tone", []string{"swimming_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fe\u200d\u2642\ufe0f", "man swimming: Medium-Dark Skin Tone", []string{"swimming_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fc\u200d\u2642\ufe0f", "man swimming: Medium-Light Skin Tone", []string{"swimming_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fd\u200d\u2642\ufe0f", "man swimming: Medium Skin Tone", []string{"swimming_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\u200d\u2640\ufe0f", "woman swimming", []string{"swimming_woman"}, "6.0", true},
+ {"\U0001f3ca\U0001f3ff\u200d\u2640\ufe0f", "woman swimming: Dark Skin Tone", []string{"swimming_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fb\u200d\u2640\ufe0f", "woman swimming: Light Skin Tone", []string{"swimming_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fe\u200d\u2640\ufe0f", "woman swimming: Medium-Dark Skin Tone", []string{"swimming_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fc\u200d\u2640\ufe0f", "woman swimming: Medium-Light Skin Tone", []string{"swimming_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3ca\U0001f3fd\u200d\u2640\ufe0f", "woman swimming: Medium Skin Tone", []string{"swimming_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1e8\U0001f1ed", "flag: Switzerland", []string{"switzerland"}, "6.0", false},
+ {"\U0001f523", "input symbols", []string{"symbols"}, "6.0", false},
+ {"\U0001f54d", "synagogue", []string{"synagogue"}, "8.0", false},
+ {"\U0001f1f8\U0001f1fe", "flag: Syria", []string{"syria"}, "6.0", false},
+ {"\U0001f489", "syringe", []string{"syringe"}, "6.0", false},
+ {"\U0001f996", "T-Rex", []string{"t-rex"}, "11.0", false},
+ {"\U0001f32e", "taco", []string{"taco"}, "8.0", false},
+ {"\U0001f389", "party popper", []string{"tada", "hooray"}, "6.0", false},
+ {"\U0001f1f9\U0001f1fc", "flag: Taiwan", []string{"taiwan"}, "6.0", false},
+ {"\U0001f1f9\U0001f1ef", "flag: Tajikistan", []string{"tajikistan"}, "6.0", false},
+ {"\U0001f961", "takeout box", []string{"takeout_box"}, "11.0", false},
+ {"\U0001fad4", "tamale", []string{"tamale"}, "13.0", false},
+ {"\U0001f38b", "tanabata tree", []string{"tanabata_tree"}, "6.0", false},
+ {"\U0001f34a", "tangerine", []string{"tangerine", "orange", "mandarin"}, "6.0", false},
+ {"\U0001f1f9\U0001f1ff", "flag: Tanzania", []string{"tanzania"}, "6.0", false},
+ {"\u2649", "Taurus", []string{"taurus"}, "", false},
+ {"\U0001f695", "taxi", []string{"taxi"}, "6.0", false},
+ {"\U0001f375", "teacup without handle", []string{"tea"}, "6.0", false},
+ {"\U0001f9d1\u200d\U0001f3eb", "teacher", []string{"teacher"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f3eb", "teacher: Dark Skin Tone", []string{"teacher_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f3eb", "teacher: Light Skin Tone", []string{"teacher_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f3eb", "teacher: Medium-Dark Skin Tone", []string{"teacher_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f3eb", "teacher: Medium-Light Skin Tone", []string{"teacher_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f3eb", "teacher: Medium Skin Tone", []string{"teacher_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001fad6", "teapot", []string{"teapot"}, "13.0", false},
+ {"\U0001f9d1\u200d\U0001f4bb", "technologist", []string{"technologist"}, "12.1", true},
+ {"\U0001f9d1\U0001f3ff\u200d\U0001f4bb", "technologist: Dark Skin Tone", []string{"technologist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fb\u200d\U0001f4bb", "technologist: Light Skin Tone", []string{"technologist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fe\u200d\U0001f4bb", "technologist: Medium-Dark Skin Tone", []string{"technologist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fc\u200d\U0001f4bb", "technologist: Medium-Light Skin Tone", []string{"technologist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d1\U0001f3fd\u200d\U0001f4bb", "technologist: Medium Skin Tone", []string{"technologist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9f8", "teddy bear", []string{"teddy_bear"}, "11.0", false},
+ {"\U0001f4de", "telephone receiver", []string{"telephone_receiver"}, "6.0", false},
+ {"\U0001f52d", "telescope", []string{"telescope"}, "6.0", false},
+ {"\U0001f3be", "tennis", []string{"tennis"}, "6.0", false},
+ {"\u26fa", "tent", []string{"tent"}, "5.2", false},
+ {"\U0001f9ea", "test tube", []string{"test_tube"}, "11.0", false},
+ {"\U0001f1f9\U0001f1ed", "flag: Thailand", []string{"thailand"}, "6.0", false},
+ {"\U0001f321\ufe0f", "thermometer", []string{"thermometer"}, "7.0", false},
+ {"\U0001f914", "thinking face", []string{"thinking"}, "8.0", false},
+ {"\U0001fa74", "thong sandal", []string{"thong_sandal"}, "13.0", false},
+ {"\U0001f4ad", "thought balloon", []string{"thought_balloon"}, "6.0", false},
+ {"\U0001f9f5", "thread", []string{"thread"}, "11.0", false},
+ {"3\ufe0f\u20e3", "keycap: 3", []string{"three"}, "", false},
+ {"\U0001f3ab", "ticket", []string{"ticket"}, "6.0", false},
+ {"\U0001f39f\ufe0f", "admission tickets", []string{"tickets"}, "7.0", false},
+ {"\U0001f42f", "tiger face", []string{"tiger"}, "6.0", false},
+ {"\U0001f405", "tiger", []string{"tiger2"}, "6.0", false},
+ {"\u23f2\ufe0f", "timer clock", []string{"timer_clock"}, "6.0", false},
+ {"\U0001f1f9\U0001f1f1", "flag: Timor-Leste", []string{"timor_leste"}, "6.0", false},
+ {"\U0001f481\u200d\u2642\ufe0f", "man tipping hand", []string{"tipping_hand_man", "sassy_man"}, "6.0", true},
+ {"\U0001f481\U0001f3ff\u200d\u2642\ufe0f", "man tipping hand: Dark Skin Tone", []string{"tipping_hand_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fb\u200d\u2642\ufe0f", "man tipping hand: Light Skin Tone", []string{"tipping_hand_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fe\u200d\u2642\ufe0f", "man tipping hand: Medium-Dark Skin Tone", []string{"tipping_hand_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fc\u200d\u2642\ufe0f", "man tipping hand: Medium-Light Skin Tone", []string{"tipping_hand_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fd\u200d\u2642\ufe0f", "man tipping hand: Medium Skin Tone", []string{"tipping_hand_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f481", "person tipping hand", []string{"tipping_hand_person", "information_desk_person"}, "6.0", true},
+ {"\U0001f481\U0001f3ff", "person tipping hand: Dark Skin Tone", []string{"tipping_hand_person_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fb", "person tipping hand: Light Skin Tone", []string{"tipping_hand_person_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fe", "person tipping hand: Medium-Dark Skin Tone", []string{"tipping_hand_person_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fc", "person tipping hand: Medium-Light Skin Tone", []string{"tipping_hand_person_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fd", "person tipping hand: Medium Skin Tone", []string{"tipping_hand_person_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\u200d\u2640\ufe0f", "woman tipping hand", []string{"tipping_hand_woman", "sassy_woman"}, "11.0", true},
+ {"\U0001f481\U0001f3ff\u200d\u2640\ufe0f", "woman tipping hand: Dark Skin Tone", []string{"tipping_hand_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fb\u200d\u2640\ufe0f", "woman tipping hand: Light Skin Tone", []string{"tipping_hand_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fe\u200d\u2640\ufe0f", "woman tipping hand: Medium-Dark Skin Tone", []string{"tipping_hand_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fc\u200d\u2640\ufe0f", "woman tipping hand: Medium-Light Skin Tone", []string{"tipping_hand_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f481\U0001f3fd\u200d\u2640\ufe0f", "woman tipping hand: Medium Skin Tone", []string{"tipping_hand_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f62b", "tired face", []string{"tired_face"}, "6.0", false},
+ {"\u2122\ufe0f", "trade mark", []string{"tm"}, "", false},
+ {"\U0001f1f9\U0001f1ec", "flag: Togo", []string{"togo"}, "6.0", false},
+ {"\U0001f6bd", "toilet", []string{"toilet"}, "6.0", false},
+ {"\U0001f1f9\U0001f1f0", "flag: Tokelau", []string{"tokelau"}, "6.0", false},
+ {"\U0001f5fc", "Tokyo tower", []string{"tokyo_tower"}, "6.0", false},
+ {"\U0001f345", "tomato", []string{"tomato"}, "6.0", false},
+ {"\U0001f1f9\U0001f1f4", "flag: Tonga", []string{"tonga"}, "6.0", false},
+ {"\U0001f445", "tongue", []string{"tongue"}, "6.0", false},
+ {"\U0001f9f0", "toolbox", []string{"toolbox"}, "11.0", false},
+ {"\U0001f9b7", "tooth", []string{"tooth"}, "11.0", false},
+ {"\U0001faa5", "toothbrush", []string{"toothbrush"}, "13.0", false},
+ {"\U0001f51d", "TOP arrow", []string{"top"}, "6.0", false},
+ {"\U0001f3a9", "top hat", []string{"tophat"}, "6.0", false},
+ {"\U0001f32a\ufe0f", "tornado", []string{"tornado"}, "7.0", false},
+ {"\U0001f1f9\U0001f1f7", "flag: Turkey", []string{"tr"}, "8.0", false},
+ {"\U0001f5b2\ufe0f", "trackball", []string{"trackball"}, "7.0", false},
+ {"\U0001f69c", "tractor", []string{"tractor"}, "6.0", false},
+ {"\U0001f6a5", "horizontal traffic light", []string{"traffic_light"}, "6.0", false},
+ {"\U0001f68b", "tram car", []string{"train"}, "6.0", false},
+ {"\U0001f686", "train", []string{"train2"}, "6.0", false},
+ {"\U0001f68a", "tram", []string{"tram"}, "6.0", false},
+ {"\U0001f3f3\ufe0f\u200d\u26a7\ufe0f", "transgender flag", []string{"transgender_flag"}, "13.0", false},
+ {"\u26a7\ufe0f", "transgender symbol", []string{"transgender_symbol"}, "13.0", false},
+ {"\U0001f6a9", "triangular flag", []string{"triangular_flag_on_post"}, "6.0", false},
+ {"\U0001f4d0", "triangular ruler", []string{"triangular_ruler"}, "6.0", false},
+ {"\U0001f531", "trident emblem", []string{"trident"}, "6.0", false},
+ {"\U0001f1f9\U0001f1f9", "flag: Trinidad & Tobago", []string{"trinidad_tobago"}, "6.0", false},
+ {"\U0001f1f9\U0001f1e6", "flag: Tristan da Cunha", []string{"tristan_da_cunha"}, "11.0", false},
+ {"\U0001f624", "face with steam from nose", []string{"triumph"}, "6.0", false},
+ {"\U0001f9cc", "troll", []string{"troll"}, "14.0", false},
+ {"\U0001f68e", "trolleybus", []string{"trolleybus"}, "6.0", false},
+ {"\U0001f3c6", "trophy", []string{"trophy"}, "6.0", false},
+ {"\U0001f379", "tropical drink", []string{"tropical_drink"}, "6.0", false},
+ {"\U0001f420", "tropical fish", []string{"tropical_fish"}, "6.0", false},
+ {"\U0001f69a", "delivery truck", []string{"truck"}, "6.0", false},
+ {"\U0001f3ba", "trumpet", []string{"trumpet"}, "6.0", false},
+ {"\U0001f337", "tulip", []string{"tulip"}, "6.0", false},
+ {"\U0001f943", "tumbler glass", []string{"tumbler_glass"}, "9.0", false},
+ {"\U0001f1f9\U0001f1f3", "flag: Tunisia", []string{"tunisia"}, "6.0", false},
+ {"\U0001f983", "turkey", []string{"turkey"}, "8.0", false},
+ {"\U0001f1f9\U0001f1f2", "flag: Turkmenistan", []string{"turkmenistan"}, "6.0", false},
+ {"\U0001f1f9\U0001f1e8", "flag: Turks & Caicos Islands", []string{"turks_caicos_islands"}, "6.0", false},
+ {"\U0001f422", "turtle", []string{"turtle"}, "6.0", false},
+ {"\U0001f1f9\U0001f1fb", "flag: Tuvalu", []string{"tuvalu"}, "6.0", false},
+ {"\U0001f4fa", "television", []string{"tv"}, "6.0", false},
+ {"\U0001f500", "shuffle tracks button", []string{"twisted_rightwards_arrows"}, "6.0", false},
+ {"2\ufe0f\u20e3", "keycap: 2", []string{"two"}, "", false},
+ {"\U0001f495", "two hearts", []string{"two_hearts"}, "6.0", false},
+ {"\U0001f46c", "men holding hands", []string{"two_men_holding_hands"}, "6.0", true},
+ {"\U0001f46c\U0001f3ff", "men holding hands: Dark Skin Tone", []string{"two_men_holding_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46c\U0001f3fb", "men holding hands: Light Skin Tone", []string{"two_men_holding_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46c\U0001f3fe", "men holding hands: Medium-Dark Skin Tone", []string{"two_men_holding_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46c\U0001f3fc", "men holding hands: Medium-Light Skin Tone", []string{"two_men_holding_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46c\U0001f3fd", "men holding hands: Medium Skin Tone", []string{"two_men_holding_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f46d", "women holding hands", []string{"two_women_holding_hands"}, "6.0", true},
+ {"\U0001f46d\U0001f3ff", "women holding hands: Dark Skin Tone", []string{"two_women_holding_hands_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46d\U0001f3fb", "women holding hands: Light Skin Tone", []string{"two_women_holding_hands_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46d\U0001f3fe", "women holding hands: Medium-Dark Skin Tone", []string{"two_women_holding_hands_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f46d\U0001f3fc", "women holding hands: Medium-Light Skin Tone", []string{"two_women_holding_hands_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f46d\U0001f3fd", "women holding hands: Medium Skin Tone", []string{"two_women_holding_hands_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f239", "Japanese “discount†button", []string{"u5272"}, "6.0", false},
+ {"\U0001f234", "Japanese “passing grade†button", []string{"u5408"}, "6.0", false},
+ {"\U0001f23a", "Japanese “open for business†button", []string{"u55b6"}, "6.0", false},
+ {"\U0001f22f", "Japanese “reserved†button", []string{"u6307"}, "", false},
+ {"\U0001f237\ufe0f", "Japanese “monthly amount†button", []string{"u6708"}, "6.0", false},
+ {"\U0001f236", "Japanese “not free of charge†button", []string{"u6709"}, "6.0", false},
+ {"\U0001f235", "Japanese “no vacancy†button", []string{"u6e80"}, "6.0", false},
+ {"\U0001f21a", "Japanese “free of charge†button", []string{"u7121"}, "", false},
+ {"\U0001f238", "Japanese “application†button", []string{"u7533"}, "6.0", false},
+ {"\U0001f232", "Japanese “prohibited†button", []string{"u7981"}, "6.0", false},
+ {"\U0001f233", "Japanese “vacancy†button", []string{"u7a7a"}, "6.0", false},
+ {"\U0001f1fa\U0001f1ec", "flag: Uganda", []string{"uganda"}, "6.0", false},
+ {"\U0001f1fa\U0001f1e6", "flag: Ukraine", []string{"ukraine"}, "6.0", false},
+ {"\u2614", "umbrella with rain drops", []string{"umbrella"}, "4.0", false},
+ {"\U0001f612", "unamused face", []string{"unamused"}, "6.0", false},
+ {"\U0001f51e", "no one under eighteen", []string{"underage"}, "6.0", false},
+ {"\U0001f984", "unicorn", []string{"unicorn"}, "8.0", false},
+ {"\U0001f1e6\U0001f1ea", "flag: United Arab Emirates", []string{"united_arab_emirates"}, "6.0", false},
+ {"\U0001f1fa\U0001f1f3", "flag: United Nations", []string{"united_nations"}, "11.0", false},
+ {"\U0001f513", "unlocked", []string{"unlock"}, "6.0", false},
+ {"\U0001f199", "UP! button", []string{"up"}, "6.0", false},
+ {"\U0001f643", "upside-down face", []string{"upside_down_face"}, "8.0", false},
+ {"\U0001f1fa\U0001f1fe", "flag: Uruguay", []string{"uruguay"}, "6.0", false},
+ {"\U0001f1fa\U0001f1f8", "flag: United States", []string{"us"}, "6.0", false},
+ {"\U0001f1fa\U0001f1f2", "flag: U.S. Outlying Islands", []string{"us_outlying_islands"}, "11.0", false},
+ {"\U0001f1fb\U0001f1ee", "flag: U.S. Virgin Islands", []string{"us_virgin_islands"}, "6.0", false},
+ {"\U0001f1fa\U0001f1ff", "flag: Uzbekistan", []string{"uzbekistan"}, "6.0", false},
+ {"\u270c\ufe0f", "victory hand", []string{"v"}, "", true},
+ {"\u270c\U0001f3ff\ufe0f", "victory hand: Dark Skin Tone", []string{"v_Dark_Skin_Tone"}, "12.0", false},
+ {"\u270c\U0001f3fb\ufe0f", "victory hand: Light Skin Tone", []string{"v_Light_Skin_Tone"}, "12.0", false},
+ {"\u270c\U0001f3fe\ufe0f", "victory hand: Medium-Dark Skin Tone", []string{"v_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u270c\U0001f3fc\ufe0f", "victory hand: Medium-Light Skin Tone", []string{"v_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u270c\U0001f3fd\ufe0f", "victory hand: Medium Skin Tone", []string{"v_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db", "vampire", []string{"vampire"}, "11.0", true},
+ {"\U0001f9db\U0001f3ff", "vampire: Dark Skin Tone", []string{"vampire_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fb", "vampire: Light Skin Tone", []string{"vampire_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fe", "vampire: Medium-Dark Skin Tone", []string{"vampire_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fc", "vampire: Medium-Light Skin Tone", []string{"vampire_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fd", "vampire: Medium Skin Tone", []string{"vampire_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\u200d\u2642\ufe0f", "man vampire", []string{"vampire_man"}, "11.0", true},
+ {"\U0001f9db\U0001f3ff\u200d\u2642\ufe0f", "man vampire: Dark Skin Tone", []string{"vampire_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fb\u200d\u2642\ufe0f", "man vampire: Light Skin Tone", []string{"vampire_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fe\u200d\u2642\ufe0f", "man vampire: Medium-Dark Skin Tone", []string{"vampire_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fc\u200d\u2642\ufe0f", "man vampire: Medium-Light Skin Tone", []string{"vampire_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fd\u200d\u2642\ufe0f", "man vampire: Medium Skin Tone", []string{"vampire_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\u200d\u2640\ufe0f", "woman vampire", []string{"vampire_woman"}, "11.0", true},
+ {"\U0001f9db\U0001f3ff\u200d\u2640\ufe0f", "woman vampire: Dark Skin Tone", []string{"vampire_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fb\u200d\u2640\ufe0f", "woman vampire: Light Skin Tone", []string{"vampire_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fe\u200d\u2640\ufe0f", "woman vampire: Medium-Dark Skin Tone", []string{"vampire_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fc\u200d\u2640\ufe0f", "woman vampire: Medium-Light Skin Tone", []string{"vampire_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9db\U0001f3fd\u200d\u2640\ufe0f", "woman vampire: Medium Skin Tone", []string{"vampire_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1fb\U0001f1fa", "flag: Vanuatu", []string{"vanuatu"}, "6.0", false},
+ {"\U0001f1fb\U0001f1e6", "flag: Vatican City", []string{"vatican_city"}, "6.0", false},
+ {"\U0001f1fb\U0001f1ea", "flag: Venezuela", []string{"venezuela"}, "6.0", false},
+ {"\U0001f6a6", "vertical traffic light", []string{"vertical_traffic_light"}, "6.0", false},
+ {"\U0001f4fc", "videocassette", []string{"vhs"}, "6.0", false},
+ {"\U0001f4f3", "vibration mode", []string{"vibration_mode"}, "6.0", false},
+ {"\U0001f4f9", "video camera", []string{"video_camera"}, "6.0", false},
+ {"\U0001f3ae", "video game", []string{"video_game"}, "6.0", false},
+ {"\U0001f1fb\U0001f1f3", "flag: Vietnam", []string{"vietnam"}, "6.0", false},
+ {"\U0001f3bb", "violin", []string{"violin"}, "6.0", false},
+ {"\u264d", "Virgo", []string{"virgo"}, "", false},
+ {"\U0001f30b", "volcano", []string{"volcano"}, "6.0", false},
+ {"\U0001f3d0", "volleyball", []string{"volleyball"}, "8.0", false},
+ {"\U0001f92e", "face vomiting", []string{"vomiting_face"}, "11.0", false},
+ {"\U0001f19a", "VS button", []string{"vs"}, "6.0", false},
+ {"\U0001f596", "vulcan salute", []string{"vulcan_salute"}, "7.0", true},
+ {"\U0001f596\U0001f3ff", "vulcan salute: Dark Skin Tone", []string{"vulcan_salute_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f596\U0001f3fb", "vulcan salute: Light Skin Tone", []string{"vulcan_salute_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f596\U0001f3fe", "vulcan salute: Medium-Dark Skin Tone", []string{"vulcan_salute_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f596\U0001f3fc", "vulcan salute: Medium-Light Skin Tone", []string{"vulcan_salute_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f596\U0001f3fd", "vulcan salute: Medium Skin Tone", []string{"vulcan_salute_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9c7", "waffle", []string{"waffle"}, "12.0", false},
+ {"\U0001f3f4\U000e0067\U000e0062\U000e0077\U000e006c\U000e0073\U000e007f", "flag: Wales", []string{"wales"}, "11.0", false},
+ {"\U0001f6b6", "person walking", []string{"walking"}, "6.0", true},
+ {"\U0001f6b6\U0001f3ff", "person walking: Dark Skin Tone", []string{"walking_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fb", "person walking: Light Skin Tone", []string{"walking_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fe", "person walking: Medium-Dark Skin Tone", []string{"walking_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fc", "person walking: Medium-Light Skin Tone", []string{"walking_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fd", "person walking: Medium Skin Tone", []string{"walking_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\u200d\u2642\ufe0f", "man walking", []string{"walking_man"}, "11.0", true},
+ {"\U0001f6b6\U0001f3ff\u200d\u2642\ufe0f", "man walking: Dark Skin Tone", []string{"walking_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fb\u200d\u2642\ufe0f", "man walking: Light Skin Tone", []string{"walking_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fe\u200d\u2642\ufe0f", "man walking: Medium-Dark Skin Tone", []string{"walking_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fc\u200d\u2642\ufe0f", "man walking: Medium-Light Skin Tone", []string{"walking_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fd\u200d\u2642\ufe0f", "man walking: Medium Skin Tone", []string{"walking_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\u200d\u2640\ufe0f", "woman walking", []string{"walking_woman"}, "6.0", true},
+ {"\U0001f6b6\U0001f3ff\u200d\u2640\ufe0f", "woman walking: Dark Skin Tone", []string{"walking_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fb\u200d\u2640\ufe0f", "woman walking: Light Skin Tone", []string{"walking_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fe\u200d\u2640\ufe0f", "woman walking: Medium-Dark Skin Tone", []string{"walking_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fc\u200d\u2640\ufe0f", "woman walking: Medium-Light Skin Tone", []string{"walking_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f6b6\U0001f3fd\u200d\u2640\ufe0f", "woman walking: Medium Skin Tone", []string{"walking_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1fc\U0001f1eb", "flag: Wallis & Futuna", []string{"wallis_futuna"}, "6.0", false},
+ {"\U0001f318", "waning crescent moon", []string{"waning_crescent_moon"}, "6.0", false},
+ {"\U0001f316", "waning gibbous moon", []string{"waning_gibbous_moon"}, "6.0", false},
+ {"\u26a0\ufe0f", "warning", []string{"warning"}, "4.0", false},
+ {"\U0001f5d1\ufe0f", "wastebasket", []string{"wastebasket"}, "7.0", false},
+ {"\u231a", "watch", []string{"watch"}, "", false},
+ {"\U0001f403", "water buffalo", []string{"water_buffalo"}, "6.0", false},
+ {"\U0001f93d", "person playing water polo", []string{"water_polo"}, "11.0", true},
+ {"\U0001f93d\U0001f3ff", "person playing water polo: Dark Skin Tone", []string{"water_polo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fb", "person playing water polo: Light Skin Tone", []string{"water_polo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fe", "person playing water polo: Medium-Dark Skin Tone", []string{"water_polo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fc", "person playing water polo: Medium-Light Skin Tone", []string{"water_polo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fd", "person playing water polo: Medium Skin Tone", []string{"water_polo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f349", "watermelon", []string{"watermelon"}, "6.0", false},
+ {"\U0001f44b", "waving hand", []string{"wave"}, "6.0", true},
+ {"\U0001f44b\U0001f3ff", "waving hand: Dark Skin Tone", []string{"wave_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44b\U0001f3fb", "waving hand: Light Skin Tone", []string{"wave_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44b\U0001f3fe", "waving hand: Medium-Dark Skin Tone", []string{"wave_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f44b\U0001f3fc", "waving hand: Medium-Light Skin Tone", []string{"wave_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f44b\U0001f3fd", "waving hand: Medium Skin Tone", []string{"wave_Medium_Skin_Tone"}, "12.0", false},
+ {"\u3030\ufe0f", "wavy dash", []string{"wavy_dash"}, "", false},
+ {"\U0001f312", "waxing crescent moon", []string{"waxing_crescent_moon"}, "6.0", false},
+ {"\U0001f6be", "water closet", []string{"wc"}, "6.0", false},
+ {"\U0001f629", "weary face", []string{"weary"}, "6.0", false},
+ {"\U0001f492", "wedding", []string{"wedding"}, "6.0", false},
+ {"\U0001f3cb\ufe0f", "person lifting weights", []string{"weight_lifting"}, "7.0", true},
+ {"\U0001f3cb\U0001f3ff\ufe0f", "person lifting weights: Dark Skin Tone", []string{"weight_lifting_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fb\ufe0f", "person lifting weights: Light Skin Tone", []string{"weight_lifting_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fe\ufe0f", "person lifting weights: Medium-Dark Skin Tone", []string{"weight_lifting_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fc\ufe0f", "person lifting weights: Medium-Light Skin Tone", []string{"weight_lifting_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fd\ufe0f", "person lifting weights: Medium Skin Tone", []string{"weight_lifting_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\ufe0f\u200d\u2642\ufe0f", "man lifting weights", []string{"weight_lifting_man"}, "11.0", true},
+ {"\U0001f3cb\U0001f3ff\ufe0f\u200d\u2642\ufe0f", "man lifting weights: Dark Skin Tone", []string{"weight_lifting_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fb\ufe0f\u200d\u2642\ufe0f", "man lifting weights: Light Skin Tone", []string{"weight_lifting_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fe\ufe0f\u200d\u2642\ufe0f", "man lifting weights: Medium-Dark Skin Tone", []string{"weight_lifting_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fc\ufe0f\u200d\u2642\ufe0f", "man lifting weights: Medium-Light Skin Tone", []string{"weight_lifting_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fd\ufe0f\u200d\u2642\ufe0f", "man lifting weights: Medium Skin Tone", []string{"weight_lifting_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\ufe0f\u200d\u2640\ufe0f", "woman lifting weights", []string{"weight_lifting_woman"}, "6.0", true},
+ {"\U0001f3cb\U0001f3ff\ufe0f\u200d\u2640\ufe0f", "woman lifting weights: Dark Skin Tone", []string{"weight_lifting_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fb\ufe0f\u200d\u2640\ufe0f", "woman lifting weights: Light Skin Tone", []string{"weight_lifting_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fe\ufe0f\u200d\u2640\ufe0f", "woman lifting weights: Medium-Dark Skin Tone", []string{"weight_lifting_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fc\ufe0f\u200d\u2640\ufe0f", "woman lifting weights: Medium-Light Skin Tone", []string{"weight_lifting_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f3cb\U0001f3fd\ufe0f\u200d\u2640\ufe0f", "woman lifting weights: Medium Skin Tone", []string{"weight_lifting_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f1ea\U0001f1ed", "flag: Western Sahara", []string{"western_sahara"}, "6.0", false},
+ {"\U0001f433", "spouting whale", []string{"whale"}, "6.0", false},
+ {"\U0001f40b", "whale", []string{"whale2"}, "6.0", false},
+ {"\U0001f6de", "wheel", []string{"wheel"}, "14.0", false},
+ {"\u2638\ufe0f", "wheel of dharma", []string{"wheel_of_dharma"}, "", false},
+ {"\u267f", "wheelchair symbol", []string{"wheelchair"}, "4.1", false},
+ {"\u2705", "check mark button", []string{"white_check_mark"}, "6.0", false},
+ {"\u26aa", "white circle", []string{"white_circle"}, "4.1", false},
+ {"\U0001f3f3\ufe0f", "white flag", []string{"white_flag"}, "7.0", false},
+ {"\U0001f4ae", "white flower", []string{"white_flower"}, "6.0", false},
+ {"\U0001f468\u200d\U0001f9b3", "man: white hair", []string{"white_haired_man"}, "11.0", true},
+ {"\U0001f468\U0001f3ff\u200d\U0001f9b3", "man: white hair: Dark Skin Tone", []string{"white_haired_man_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fb\u200d\U0001f9b3", "man: white hair: Light Skin Tone", []string{"white_haired_man_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fe\u200d\U0001f9b3", "man: white hair: Medium-Dark Skin Tone", []string{"white_haired_man_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fc\u200d\U0001f9b3", "man: white hair: Medium-Light Skin Tone", []string{"white_haired_man_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f468\U0001f3fd\u200d\U0001f9b3", "man: white hair: Medium Skin Tone", []string{"white_haired_man_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9b3", "woman: white hair", []string{"white_haired_woman"}, "11.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9b3", "woman: white hair: Dark Skin Tone", []string{"white_haired_woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9b3", "woman: white hair: Light Skin Tone", []string{"white_haired_woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9b3", "woman: white hair: Medium-Dark Skin Tone", []string{"white_haired_woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9b3", "woman: white hair: Medium-Light Skin Tone", []string{"white_haired_woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9b3", "woman: white hair: Medium Skin Tone", []string{"white_haired_woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f90d", "white heart", []string{"white_heart"}, "12.0", false},
+ {"\u2b1c", "white large square", []string{"white_large_square"}, "5.1", false},
+ {"\u25fd", "white medium-small square", []string{"white_medium_small_square"}, "3.2", false},
+ {"\u25fb\ufe0f", "white medium square", []string{"white_medium_square"}, "3.2", false},
+ {"\u25ab\ufe0f", "white small square", []string{"white_small_square"}, "", false},
+ {"\U0001f533", "white square button", []string{"white_square_button"}, "6.0", false},
+ {"\U0001f940", "wilted flower", []string{"wilted_flower"}, "9.0", false},
+ {"\U0001f390", "wind chime", []string{"wind_chime"}, "6.0", false},
+ {"\U0001f32c\ufe0f", "wind face", []string{"wind_face"}, "7.0", false},
+ {"\U0001fa9f", "window", []string{"window"}, "13.0", false},
+ {"\U0001f377", "wine glass", []string{"wine_glass"}, "6.0", false},
+ {"\U0001fabd", "wing", []string{"wing"}, "15.0", false},
+ {"\U0001f609", "winking face", []string{"wink"}, "6.0", false},
+ {"\U0001f6dc", "wireless", []string{"wireless"}, "15.0", false},
+ {"\U0001f43a", "wolf", []string{"wolf"}, "6.0", false},
+ {"\U0001f469", "woman", []string{"woman"}, "6.0", true},
+ {"\U0001f469\U0001f3ff", "woman: Dark Skin Tone", []string{"woman_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb", "woman: Light Skin Tone", []string{"woman_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe", "woman: Medium-Dark Skin Tone", []string{"woman_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc", "woman: Medium-Light Skin Tone", []string{"woman_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd", "woman: Medium Skin Tone", []string{"woman_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f3a8", "woman artist", []string{"woman_artist"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f3a8", "woman artist: Dark Skin Tone", []string{"woman_artist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f3a8", "woman artist: Light Skin Tone", []string{"woman_artist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f3a8", "woman artist: Medium-Dark Skin Tone", []string{"woman_artist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f3a8", "woman artist: Medium-Light Skin Tone", []string{"woman_artist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f3a8", "woman artist: Medium Skin Tone", []string{"woman_artist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f680", "woman astronaut", []string{"woman_astronaut"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f680", "woman astronaut: Dark Skin Tone", []string{"woman_astronaut_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f680", "woman astronaut: Light Skin Tone", []string{"woman_astronaut_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f680", "woman astronaut: Medium-Dark Skin Tone", []string{"woman_astronaut_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f680", "woman astronaut: Medium-Light Skin Tone", []string{"woman_astronaut_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f680", "woman astronaut: Medium Skin Tone", []string{"woman_astronaut_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\u200d\u2640\ufe0f", "woman: beard", []string{"woman_beard"}, "13.1", true},
+ {"\U0001f9d4\U0001f3ff\u200d\u2640\ufe0f", "woman: beard: Dark Skin Tone", []string{"woman_beard_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fb\u200d\u2640\ufe0f", "woman: beard: Light Skin Tone", []string{"woman_beard_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fe\u200d\u2640\ufe0f", "woman: beard: Medium-Dark Skin Tone", []string{"woman_beard_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fc\u200d\u2640\ufe0f", "woman: beard: Medium-Light Skin Tone", []string{"woman_beard_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d4\U0001f3fd\u200d\u2640\ufe0f", "woman: beard: Medium Skin Tone", []string{"woman_beard_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\u200d\u2640\ufe0f", "woman cartwheeling", []string{"woman_cartwheeling"}, "", true},
+ {"\U0001f938\U0001f3ff\u200d\u2640\ufe0f", "woman cartwheeling: Dark Skin Tone", []string{"woman_cartwheeling_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fb\u200d\u2640\ufe0f", "woman cartwheeling: Light Skin Tone", []string{"woman_cartwheeling_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fe\u200d\u2640\ufe0f", "woman cartwheeling: Medium-Dark Skin Tone", []string{"woman_cartwheeling_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fc\u200d\u2640\ufe0f", "woman cartwheeling: Medium-Light Skin Tone", []string{"woman_cartwheeling_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f938\U0001f3fd\u200d\u2640\ufe0f", "woman cartwheeling: Medium Skin Tone", []string{"woman_cartwheeling_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f373", "woman cook", []string{"woman_cook"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f373", "woman cook: Dark Skin Tone", []string{"woman_cook_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f373", "woman cook: Light Skin Tone", []string{"woman_cook_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f373", "woman cook: Medium-Dark Skin Tone", []string{"woman_cook_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f373", "woman cook: Medium-Light Skin Tone", []string{"woman_cook_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f373", "woman cook: Medium Skin Tone", []string{"woman_cook_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f483", "woman dancing", []string{"woman_dancing", "dancer"}, "6.0", true},
+ {"\U0001f483\U0001f3ff", "woman dancing: Dark Skin Tone", []string{"woman_dancing_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f483\U0001f3fb", "woman dancing: Light Skin Tone", []string{"woman_dancing_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f483\U0001f3fe", "woman dancing: Medium-Dark Skin Tone", []string{"woman_dancing_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f483\U0001f3fc", "woman dancing: Medium-Light Skin Tone", []string{"woman_dancing_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f483\U0001f3fd", "woman dancing: Medium Skin Tone", []string{"woman_dancing_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\u200d\u2640\ufe0f", "woman facepalming", []string{"woman_facepalming"}, "9.0", true},
+ {"\U0001f926\U0001f3ff\u200d\u2640\ufe0f", "woman facepalming: Dark Skin Tone", []string{"woman_facepalming_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fb\u200d\u2640\ufe0f", "woman facepalming: Light Skin Tone", []string{"woman_facepalming_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fe\u200d\u2640\ufe0f", "woman facepalming: Medium-Dark Skin Tone", []string{"woman_facepalming_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fc\u200d\u2640\ufe0f", "woman facepalming: Medium-Light Skin Tone", []string{"woman_facepalming_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f926\U0001f3fd\u200d\u2640\ufe0f", "woman facepalming: Medium Skin Tone", []string{"woman_facepalming_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f3ed", "woman factory worker", []string{"woman_factory_worker"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f3ed", "woman factory worker: Dark Skin Tone", []string{"woman_factory_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f3ed", "woman factory worker: Light Skin Tone", []string{"woman_factory_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f3ed", "woman factory worker: Medium-Dark Skin Tone", []string{"woman_factory_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f3ed", "woman factory worker: Medium-Light Skin Tone", []string{"woman_factory_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f3ed", "woman factory worker: Medium Skin Tone", []string{"woman_factory_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f33e", "woman farmer", []string{"woman_farmer"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f33e", "woman farmer: Dark Skin Tone", []string{"woman_farmer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f33e", "woman farmer: Light Skin Tone", []string{"woman_farmer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f33e", "woman farmer: Medium-Dark Skin Tone", []string{"woman_farmer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f33e", "woman farmer: Medium-Light Skin Tone", []string{"woman_farmer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f33e", "woman farmer: Medium Skin Tone", []string{"woman_farmer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f37c", "woman feeding baby", []string{"woman_feeding_baby"}, "13.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f37c", "woman feeding baby: Dark Skin Tone", []string{"woman_feeding_baby_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f37c", "woman feeding baby: Light Skin Tone", []string{"woman_feeding_baby_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f37c", "woman feeding baby: Medium-Dark Skin Tone", []string{"woman_feeding_baby_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f37c", "woman feeding baby: Medium-Light Skin Tone", []string{"woman_feeding_baby_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f37c", "woman feeding baby: Medium Skin Tone", []string{"woman_feeding_baby_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f692", "woman firefighter", []string{"woman_firefighter"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f692", "woman firefighter: Dark Skin Tone", []string{"woman_firefighter_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f692", "woman firefighter: Light Skin Tone", []string{"woman_firefighter_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f692", "woman firefighter: Medium-Dark Skin Tone", []string{"woman_firefighter_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f692", "woman firefighter: Medium-Light Skin Tone", []string{"woman_firefighter_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f692", "woman firefighter: Medium Skin Tone", []string{"woman_firefighter_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2695\ufe0f", "woman health worker", []string{"woman_health_worker"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\u2695\ufe0f", "woman health worker: Dark Skin Tone", []string{"woman_health_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2695\ufe0f", "woman health worker: Light Skin Tone", []string{"woman_health_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2695\ufe0f", "woman health worker: Medium-Dark Skin Tone", []string{"woman_health_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2695\ufe0f", "woman health worker: Medium-Light Skin Tone", []string{"woman_health_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2695\ufe0f", "woman health worker: Medium Skin Tone", []string{"woman_health_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9bd", "woman in manual wheelchair", []string{"woman_in_manual_wheelchair"}, "12.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9bd", "woman in manual wheelchair: Dark Skin Tone", []string{"woman_in_manual_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9bd", "woman in manual wheelchair: Light Skin Tone", []string{"woman_in_manual_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Dark Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Light Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9bd", "woman in manual wheelchair: Medium Skin Tone", []string{"woman_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9bc", "woman in motorized wheelchair", []string{"woman_in_motorized_wheelchair"}, "12.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9bc", "woman in motorized wheelchair: Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9bc", "woman in motorized wheelchair: Light Skin Tone", []string{"woman_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Light Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9bc", "woman in motorized wheelchair: Medium Skin Tone", []string{"woman_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\u200d\u2640\ufe0f", "woman in tuxedo", []string{"woman_in_tuxedo"}, "13.0", true},
+ {"\U0001f935\U0001f3ff\u200d\u2640\ufe0f", "woman in tuxedo: Dark Skin Tone", []string{"woman_in_tuxedo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fb\u200d\u2640\ufe0f", "woman in tuxedo: Light Skin Tone", []string{"woman_in_tuxedo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fe\u200d\u2640\ufe0f", "woman in tuxedo: Medium-Dark Skin Tone", []string{"woman_in_tuxedo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fc\u200d\u2640\ufe0f", "woman in tuxedo: Medium-Light Skin Tone", []string{"woman_in_tuxedo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f935\U0001f3fd\u200d\u2640\ufe0f", "woman in tuxedo: Medium Skin Tone", []string{"woman_in_tuxedo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2696\ufe0f", "woman judge", []string{"woman_judge"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\u2696\ufe0f", "woman judge: Dark Skin Tone", []string{"woman_judge_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2696\ufe0f", "woman judge: Light Skin Tone", []string{"woman_judge_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2696\ufe0f", "woman judge: Medium-Dark Skin Tone", []string{"woman_judge_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2696\ufe0f", "woman judge: Medium-Light Skin Tone", []string{"woman_judge_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2696\ufe0f", "woman judge: Medium Skin Tone", []string{"woman_judge_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\u200d\u2640\ufe0f", "woman juggling", []string{"woman_juggling"}, "9.0", true},
+ {"\U0001f939\U0001f3ff\u200d\u2640\ufe0f", "woman juggling: Dark Skin Tone", []string{"woman_juggling_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fb\u200d\u2640\ufe0f", "woman juggling: Light Skin Tone", []string{"woman_juggling_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fe\u200d\u2640\ufe0f", "woman juggling: Medium-Dark Skin Tone", []string{"woman_juggling_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fc\u200d\u2640\ufe0f", "woman juggling: Medium-Light Skin Tone", []string{"woman_juggling_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f939\U0001f3fd\u200d\u2640\ufe0f", "woman juggling: Medium Skin Tone", []string{"woman_juggling_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f527", "woman mechanic", []string{"woman_mechanic"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f527", "woman mechanic: Dark Skin Tone", []string{"woman_mechanic_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f527", "woman mechanic: Light Skin Tone", []string{"woman_mechanic_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f527", "woman mechanic: Medium-Dark Skin Tone", []string{"woman_mechanic_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f527", "woman mechanic: Medium-Light Skin Tone", []string{"woman_mechanic_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f527", "woman mechanic: Medium Skin Tone", []string{"woman_mechanic_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f4bc", "woman office worker", []string{"woman_office_worker"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f4bc", "woman office worker: Dark Skin Tone", []string{"woman_office_worker_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f4bc", "woman office worker: Light Skin Tone", []string{"woman_office_worker_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f4bc", "woman office worker: Medium-Dark Skin Tone", []string{"woman_office_worker_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f4bc", "woman office worker: Medium-Light Skin Tone", []string{"woman_office_worker_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f4bc", "woman office worker: Medium Skin Tone", []string{"woman_office_worker_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\u2708\ufe0f", "woman pilot", []string{"woman_pilot"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\u2708\ufe0f", "woman pilot: Dark Skin Tone", []string{"woman_pilot_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\u2708\ufe0f", "woman pilot: Light Skin Tone", []string{"woman_pilot_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\u2708\ufe0f", "woman pilot: Medium-Dark Skin Tone", []string{"woman_pilot_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\u2708\ufe0f", "woman pilot: Medium-Light Skin Tone", []string{"woman_pilot_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\u2708\ufe0f", "woman pilot: Medium Skin Tone", []string{"woman_pilot_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\u200d\u2640\ufe0f", "woman playing handball", []string{"woman_playing_handball"}, "9.0", true},
+ {"\U0001f93e\U0001f3ff\u200d\u2640\ufe0f", "woman playing handball: Dark Skin Tone", []string{"woman_playing_handball_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fb\u200d\u2640\ufe0f", "woman playing handball: Light Skin Tone", []string{"woman_playing_handball_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fe\u200d\u2640\ufe0f", "woman playing handball: Medium-Dark Skin Tone", []string{"woman_playing_handball_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fc\u200d\u2640\ufe0f", "woman playing handball: Medium-Light Skin Tone", []string{"woman_playing_handball_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93e\U0001f3fd\u200d\u2640\ufe0f", "woman playing handball: Medium Skin Tone", []string{"woman_playing_handball_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\u200d\u2640\ufe0f", "woman playing water polo", []string{"woman_playing_water_polo"}, "9.0", true},
+ {"\U0001f93d\U0001f3ff\u200d\u2640\ufe0f", "woman playing water polo: Dark Skin Tone", []string{"woman_playing_water_polo_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fb\u200d\u2640\ufe0f", "woman playing water polo: Light Skin Tone", []string{"woman_playing_water_polo_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fe\u200d\u2640\ufe0f", "woman playing water polo: Medium-Dark Skin Tone", []string{"woman_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fc\u200d\u2640\ufe0f", "woman playing water polo: Medium-Light Skin Tone", []string{"woman_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f93d\U0001f3fd\u200d\u2640\ufe0f", "woman playing water polo: Medium Skin Tone", []string{"woman_playing_water_polo_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f52c", "woman scientist", []string{"woman_scientist"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f52c", "woman scientist: Dark Skin Tone", []string{"woman_scientist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f52c", "woman scientist: Light Skin Tone", []string{"woman_scientist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f52c", "woman scientist: Medium-Dark Skin Tone", []string{"woman_scientist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f52c", "woman scientist: Medium-Light Skin Tone", []string{"woman_scientist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f52c", "woman scientist: Medium Skin Tone", []string{"woman_scientist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\u200d\u2640\ufe0f", "woman shrugging", []string{"woman_shrugging"}, "9.0", true},
+ {"\U0001f937\U0001f3ff\u200d\u2640\ufe0f", "woman shrugging: Dark Skin Tone", []string{"woman_shrugging_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fb\u200d\u2640\ufe0f", "woman shrugging: Light Skin Tone", []string{"woman_shrugging_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fe\u200d\u2640\ufe0f", "woman shrugging: Medium-Dark Skin Tone", []string{"woman_shrugging_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fc\u200d\u2640\ufe0f", "woman shrugging: Medium-Light Skin Tone", []string{"woman_shrugging_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f937\U0001f3fd\u200d\u2640\ufe0f", "woman shrugging: Medium Skin Tone", []string{"woman_shrugging_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f3a4", "woman singer", []string{"woman_singer"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f3a4", "woman singer: Dark Skin Tone", []string{"woman_singer_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f3a4", "woman singer: Light Skin Tone", []string{"woman_singer_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f3a4", "woman singer: Medium-Dark Skin Tone", []string{"woman_singer_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f3a4", "woman singer: Medium-Light Skin Tone", []string{"woman_singer_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f3a4", "woman singer: Medium Skin Tone", []string{"woman_singer_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f393", "woman student", []string{"woman_student"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f393", "woman student: Dark Skin Tone", []string{"woman_student_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f393", "woman student: Light Skin Tone", []string{"woman_student_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f393", "woman student: Medium-Dark Skin Tone", []string{"woman_student_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f393", "woman student: Medium-Light Skin Tone", []string{"woman_student_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f393", "woman student: Medium Skin Tone", []string{"woman_student_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f3eb", "woman teacher", []string{"woman_teacher"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f3eb", "woman teacher: Dark Skin Tone", []string{"woman_teacher_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f3eb", "woman teacher: Light Skin Tone", []string{"woman_teacher_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f3eb", "woman teacher: Medium-Dark Skin Tone", []string{"woman_teacher_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f3eb", "woman teacher: Medium-Light Skin Tone", []string{"woman_teacher_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f3eb", "woman teacher: Medium Skin Tone", []string{"woman_teacher_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f4bb", "woman technologist", []string{"woman_technologist"}, "", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f4bb", "woman technologist: Dark Skin Tone", []string{"woman_technologist_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f4bb", "woman technologist: Light Skin Tone", []string{"woman_technologist_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f4bb", "woman technologist: Medium-Dark Skin Tone", []string{"woman_technologist_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f4bb", "woman technologist: Medium-Light Skin Tone", []string{"woman_technologist_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f4bb", "woman technologist: Medium Skin Tone", []string{"woman_technologist_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d5", "woman with headscarf", []string{"woman_with_headscarf"}, "11.0", true},
+ {"\U0001f9d5\U0001f3ff", "woman with headscarf: Dark Skin Tone", []string{"woman_with_headscarf_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d5\U0001f3fb", "woman with headscarf: Light Skin Tone", []string{"woman_with_headscarf_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d5\U0001f3fe", "woman with headscarf: Medium-Dark Skin Tone", []string{"woman_with_headscarf_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d5\U0001f3fc", "woman with headscarf: Medium-Light Skin Tone", []string{"woman_with_headscarf_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f9d5\U0001f3fd", "woman with headscarf: Medium Skin Tone", []string{"woman_with_headscarf_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\u200d\U0001f9af", "woman with white cane", []string{"woman_with_probing_cane"}, "12.0", true},
+ {"\U0001f469\U0001f3ff\u200d\U0001f9af", "woman with white cane: Dark Skin Tone", []string{"woman_with_probing_cane_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fb\u200d\U0001f9af", "woman with white cane: Light Skin Tone", []string{"woman_with_probing_cane_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fe\u200d\U0001f9af", "woman with white cane: Medium-Dark Skin Tone", []string{"woman_with_probing_cane_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fc\u200d\U0001f9af", "woman with white cane: Medium-Light Skin Tone", []string{"woman_with_probing_cane_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f469\U0001f3fd\u200d\U0001f9af", "woman with white cane: Medium Skin Tone", []string{"woman_with_probing_cane_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\u200d\u2640\ufe0f", "woman wearing turban", []string{"woman_with_turban"}, "6.0", true},
+ {"\U0001f473\U0001f3ff\u200d\u2640\ufe0f", "woman wearing turban: Dark Skin Tone", []string{"woman_with_turban_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fb\u200d\u2640\ufe0f", "woman wearing turban: Light Skin Tone", []string{"woman_with_turban_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fe\u200d\u2640\ufe0f", "woman wearing turban: Medium-Dark Skin Tone", []string{"woman_with_turban_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fc\u200d\u2640\ufe0f", "woman wearing turban: Medium-Light Skin Tone", []string{"woman_with_turban_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f473\U0001f3fd\u200d\u2640\ufe0f", "woman wearing turban: Medium Skin Tone", []string{"woman_with_turban_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\u200d\u2640\ufe0f", "woman with veil", []string{"woman_with_veil", "bride_with_veil"}, "13.0", true},
+ {"\U0001f470\U0001f3ff\u200d\u2640\ufe0f", "woman with veil: Dark Skin Tone", []string{"woman_with_veil_Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fb\u200d\u2640\ufe0f", "woman with veil: Light Skin Tone", []string{"woman_with_veil_Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fe\u200d\u2640\ufe0f", "woman with veil: Medium-Dark Skin Tone", []string{"woman_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fc\u200d\u2640\ufe0f", "woman with veil: Medium-Light Skin Tone", []string{"woman_with_veil_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\U0001f470\U0001f3fd\u200d\u2640\ufe0f", "woman with veil: Medium Skin Tone", []string{"woman_with_veil_Medium_Skin_Tone"}, "12.0", false},
+ {"\U0001f45a", "woman’s clothes", []string{"womans_clothes"}, "6.0", false},
+ {"\U0001f452", "woman’s hat", []string{"womans_hat"}, "6.0", false},
+ {"\U0001f93c\u200d\u2640\ufe0f", "women wrestling", []string{"women_wrestling"}, "9.0", false},
+ {"\U0001f6ba", "women’s room", []string{"womens"}, "6.0", false},
+ {"\U0001fab5", "wood", []string{"wood"}, "13.0", false},
+ {"\U0001f974", "woozy face", []string{"woozy_face"}, "11.0", false},
+ {"\U0001f5fa\ufe0f", "world map", []string{"world_map"}, "7.0", false},
+ {"\U0001fab1", "worm", []string{"worm"}, "13.0", false},
+ {"\U0001f61f", "worried face", []string{"worried"}, "6.1", false},
+ {"\U0001f527", "wrench", []string{"wrench"}, "6.0", false},
+ {"\U0001f93c", "people wrestling", []string{"wrestling"}, "11.0", false},
+ {"\u270d\ufe0f", "writing hand", []string{"writing_hand"}, "", true},
+ {"\u270d\U0001f3ff\ufe0f", "writing hand: Dark Skin Tone", []string{"writing_hand_Dark_Skin_Tone"}, "12.0", false},
+ {"\u270d\U0001f3fb\ufe0f", "writing hand: Light Skin Tone", []string{"writing_hand_Light_Skin_Tone"}, "12.0", false},
+ {"\u270d\U0001f3fe\ufe0f", "writing hand: Medium-Dark Skin Tone", []string{"writing_hand_Medium-Dark_Skin_Tone"}, "12.0", false},
+ {"\u270d\U0001f3fc\ufe0f", "writing hand: Medium-Light Skin Tone", []string{"writing_hand_Medium-Light_Skin_Tone"}, "12.0", false},
+ {"\u270d\U0001f3fd\ufe0f", "writing hand: Medium Skin Tone", []string{"writing_hand_Medium_Skin_Tone"}, "12.0", false},
+ {"\u274c", "cross mark", []string{"x"}, "6.0", false},
+ {"\U0001fa7b", "x-ray", []string{"x_ray"}, "14.0", false},
+ {"\U0001f9f6", "yarn", []string{"yarn"}, "11.0", false},
+ {"\U0001f971", "yawning face", []string{"yawning_face"}, "12.0", false},
+ {"\U0001f7e1", "yellow circle", []string{"yellow_circle"}, "12.0", false},
+ {"\U0001f49b", "yellow heart", []string{"yellow_heart"}, "6.0", false},
+ {"\U0001f7e8", "yellow square", []string{"yellow_square"}, "12.0", false},
+ {"\U0001f1fe\U0001f1ea", "flag: Yemen", []string{"yemen"}, "6.0", false},
+ {"\U0001f4b4", "yen banknote", []string{"yen"}, "6.0", false},
+ {"\u262f\ufe0f", "yin yang", []string{"yin_yang"}, "", false},
+ {"\U0001fa80", "yo-yo", []string{"yo_yo"}, "12.0", false},
+ {"\U0001f60b", "face savoring food", []string{"yum"}, "6.0", false},
+ {"\U0001f1ff\U0001f1f2", "flag: Zambia", []string{"zambia"}, "6.0", false},
+ {"\U0001f92a", "zany face", []string{"zany_face"}, "11.0", false},
+ {"\u26a1", "high voltage", []string{"zap"}, "4.0", false},
+ {"\U0001f993", "zebra", []string{"zebra"}, "11.0", false},
+ {"0\ufe0f\u20e3", "keycap: 0", []string{"zero"}, "", false},
+ {"\U0001f1ff\U0001f1fc", "flag: Zimbabwe", []string{"zimbabwe"}, "6.0", false},
+ {"\U0001f910", "zipper-mouth face", []string{"zipper_mouth_face"}, "8.0", false},
+ {"\U0001f9df", "zombie", []string{"zombie"}, "11.0", false},
+ {"\U0001f9df\u200d\u2642\ufe0f", "man zombie", []string{"zombie_man"}, "11.0", false},
+ {"\U0001f9df\u200d\u2640\ufe0f", "woman zombie", []string{"zombie_woman"}, "11.0", false},
+ {"\U0001f4a4", "ZZZ", []string{"zzz"}, "6.0", false},
+}
diff --git a/modules/emoji/emoji_test.go b/modules/emoji/emoji_test.go
new file mode 100644
index 0000000..2526cd1
--- /dev/null
+++ b/modules/emoji/emoji_test.go
@@ -0,0 +1,99 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Copyright 2015 Kenneth Shaw
+// SPDX-License-Identifier: MIT
+
+package emoji
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDumpInfo(t *testing.T) {
+ t.Logf("codes: %d", len(codeMap))
+ t.Logf("aliases: %d", len(aliasMap))
+}
+
+func TestLookup(t *testing.T) {
+ a := FromCode("\U0001f37a")
+ b := FromCode("ðŸº")
+ c := FromAlias(":beer:")
+ d := FromAlias("beer")
+
+ if !reflect.DeepEqual(a, b) {
+ t.Errorf("a and b should equal")
+ }
+ if !reflect.DeepEqual(b, c) {
+ t.Errorf("b and c should equal")
+ }
+ if !reflect.DeepEqual(c, d) {
+ t.Errorf("c and d should equal")
+ }
+ if !reflect.DeepEqual(a, d) {
+ t.Errorf("a and d should equal")
+ }
+
+ m := FromCode("\U0001f44d")
+ n := FromAlias(":thumbsup:")
+ o := FromAlias("+1")
+
+ if !reflect.DeepEqual(m, n) {
+ t.Errorf("m and n should equal")
+ }
+ if !reflect.DeepEqual(n, o) {
+ t.Errorf("n and o should equal")
+ }
+ if !reflect.DeepEqual(m, o) {
+ t.Errorf("m and o should equal")
+ }
+}
+
+func TestReplacers(t *testing.T) {
+ tests := []struct {
+ f func(string) string
+ v, exp string
+ }{
+ {ReplaceCodes, ":thumbsup: +1 for \U0001f37a! 🺠\U0001f44d", ":thumbsup: +1 for :beer:! :beer: :+1:"},
+ {ReplaceAliases, ":thumbsup: +1 :+1: :beer:", "\U0001f44d +1 \U0001f44d \U0001f37a"},
+ }
+
+ for i, x := range tests {
+ s := x.f(x.v)
+ if s != x.exp {
+ t.Errorf("test %d `%s` expected `%s`, got: `%s`", i, x.v, x.exp, s)
+ }
+ }
+}
+
+func TestFindEmojiSubmatchIndex(t *testing.T) {
+ type testcase struct {
+ teststring string
+ expected []int
+ }
+
+ testcases := []testcase{
+ {
+ "\U0001f44d",
+ []int{0, len("\U0001f44d")},
+ },
+ {
+ "\U0001f44d +1 \U0001f44d \U0001f37a",
+ []int{0, 4},
+ },
+ {
+ " \U0001f44d",
+ []int{1, 1 + len("\U0001f44d")},
+ },
+ {
+ string([]byte{'\u0001'}) + "\U0001f44d",
+ []int{1, 1 + len("\U0001f44d")},
+ },
+ }
+
+ for _, kase := range testcases {
+ actual := FindEmojiSubmatchIndex(kase.teststring)
+ assert.Equal(t, kase.expected, actual)
+ }
+}
diff --git a/modules/eventsource/event.go b/modules/eventsource/event.go
new file mode 100644
index 0000000..ebcca50
--- /dev/null
+++ b/modules/eventsource/event.go
@@ -0,0 +1,118 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eventsource
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+func wrapNewlines(w io.Writer, prefix, value []byte) (sum int64, err error) {
+ if len(value) == 0 {
+ return 0, nil
+ }
+ var n int
+ last := 0
+ for j := bytes.IndexByte(value, '\n'); j > -1; j = bytes.IndexByte(value[last:], '\n') {
+ n, err = w.Write(prefix)
+ sum += int64(n)
+ if err != nil {
+ return sum, err
+ }
+ n, err = w.Write(value[last : last+j+1])
+ sum += int64(n)
+ if err != nil {
+ return sum, err
+ }
+ last += j + 1
+ }
+ n, err = w.Write(prefix)
+ sum += int64(n)
+ if err != nil {
+ return sum, err
+ }
+ n, err = w.Write(value[last:])
+ sum += int64(n)
+ if err != nil {
+ return sum, err
+ }
+ n, err = w.Write([]byte("\n"))
+ sum += int64(n)
+ return sum, err
+}
+
+// Event is an eventsource event, not all fields need to be set
+type Event struct {
+ // Name represents the value of the event: tag in the stream
+ Name string
+ // Data is either JSONified []byte or any that can be JSONd
+ Data any
+ // ID represents the ID of an event
+ ID string
+ // Retry tells the receiver only to attempt to reconnect to the source after this time
+ Retry time.Duration
+}
+
+// WriteTo writes data to w until there's no more data to write or when an error occurs.
+// The return value n is the number of bytes written. Any error encountered during the write is also returned.
+func (e *Event) WriteTo(w io.Writer) (int64, error) {
+ sum := int64(0)
+ var nint int
+ n, err := wrapNewlines(w, []byte("event: "), []byte(e.Name))
+ sum += n
+ if err != nil {
+ return sum, err
+ }
+
+ if e.Data != nil {
+ var data []byte
+ switch v := e.Data.(type) {
+ case []byte:
+ data = v
+ case string:
+ data = []byte(v)
+ default:
+ var err error
+ data, err = json.Marshal(e.Data)
+ if err != nil {
+ return sum, err
+ }
+ }
+ n, err := wrapNewlines(w, []byte("data: "), data)
+ sum += n
+ if err != nil {
+ return sum, err
+ }
+ }
+
+ n, err = wrapNewlines(w, []byte("id: "), []byte(e.ID))
+ sum += n
+ if err != nil {
+ return sum, err
+ }
+
+ if e.Retry != 0 {
+ nint, err = fmt.Fprintf(w, "retry: %d\n", int64(e.Retry/time.Millisecond))
+ sum += int64(nint)
+ if err != nil {
+ return sum, err
+ }
+ }
+
+ nint, err = w.Write([]byte("\n"))
+ sum += int64(nint)
+
+ return sum, err
+}
+
+func (e *Event) String() string {
+ buf := new(strings.Builder)
+ _, _ = e.WriteTo(buf)
+ return buf.String()
+}
diff --git a/modules/eventsource/event_test.go b/modules/eventsource/event_test.go
new file mode 100644
index 0000000..4c42728
--- /dev/null
+++ b/modules/eventsource/event_test.go
@@ -0,0 +1,53 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eventsource
+
+import (
+ "bytes"
+ "testing"
+)
+
+func Test_wrapNewlines(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ value string
+ output string
+ }{
+ {
+ "check no new lines",
+ "prefix: ",
+ "value",
+ "prefix: value\n",
+ },
+ {
+ "check simple newline",
+ "prefix: ",
+ "value1\nvalue2",
+ "prefix: value1\nprefix: value2\n",
+ },
+ {
+ "check pathological newlines",
+ "p: ",
+ "\n1\n\n2\n3\n",
+ "p: \np: 1\np: \np: 2\np: 3\np: \n",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := &bytes.Buffer{}
+ gotSum, err := wrapNewlines(w, []byte(tt.prefix), []byte(tt.value))
+ if err != nil {
+ t.Errorf("wrapNewlines() error = %v", err)
+ return
+ }
+ if gotSum != int64(len(tt.output)) {
+ t.Errorf("wrapNewlines() = %v, want %v", gotSum, int64(len(tt.output)))
+ }
+ if gotW := w.String(); gotW != tt.output {
+ t.Errorf("wrapNewlines() = %v, want %v", gotW, tt.output)
+ }
+ })
+ }
+}
diff --git a/modules/eventsource/manager.go b/modules/eventsource/manager.go
new file mode 100644
index 0000000..730cacd
--- /dev/null
+++ b/modules/eventsource/manager.go
@@ -0,0 +1,79 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eventsource
+
+import (
+ "sync"
+)
+
+// Manager manages the eventsource Messengers
+type Manager struct {
+ mutex sync.Mutex
+
+ messengers map[int64]*Messenger
+ connection chan struct{}
+}
+
+var manager *Manager
+
+func init() {
+ manager = &Manager{
+ messengers: make(map[int64]*Messenger),
+ connection: make(chan struct{}, 1),
+ }
+}
+
+// GetManager returns a Manager and initializes one as singleton if there's none yet
+func GetManager() *Manager {
+ return manager
+}
+
+// Register message channel
+func (m *Manager) Register(uid int64) <-chan *Event {
+ m.mutex.Lock()
+ messenger, ok := m.messengers[uid]
+ if !ok {
+ messenger = NewMessenger(uid)
+ m.messengers[uid] = messenger
+ }
+ select {
+ case m.connection <- struct{}{}:
+ default:
+ }
+ m.mutex.Unlock()
+ return messenger.Register()
+}
+
+// Unregister message channel
+func (m *Manager) Unregister(uid int64, channel <-chan *Event) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ messenger, ok := m.messengers[uid]
+ if !ok {
+ return
+ }
+ if messenger.Unregister(channel) {
+ delete(m.messengers, uid)
+ }
+}
+
+// UnregisterAll message channels
+func (m *Manager) UnregisterAll() {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for _, messenger := range m.messengers {
+ messenger.UnregisterAll()
+ }
+ m.messengers = map[int64]*Messenger{}
+}
+
+// SendMessage sends a message to a particular user
+func (m *Manager) SendMessage(uid int64, message *Event) {
+ m.mutex.Lock()
+ messenger, ok := m.messengers[uid]
+ m.mutex.Unlock()
+ if ok {
+ messenger.SendMessage(message)
+ }
+}
diff --git a/modules/eventsource/manager_run.go b/modules/eventsource/manager_run.go
new file mode 100644
index 0000000..f66dc78
--- /dev/null
+++ b/modules/eventsource/manager_run.go
@@ -0,0 +1,115 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eventsource
+
+import (
+ "context"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// Init starts this eventsource
+func (m *Manager) Init() {
+ if setting.UI.Notification.EventSourceUpdateTime <= 0 {
+ return
+ }
+ go graceful.GetManager().RunWithShutdownContext(m.Run)
+}
+
+// Run runs the manager within a provided context
+func (m *Manager) Run(ctx context.Context) {
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: EventSource", process.SystemProcessType, true)
+ defer finished()
+
+ then := timeutil.TimeStampNow().Add(-2)
+ timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime)
+loop:
+ for {
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ break loop
+ case <-timer.C:
+ m.mutex.Lock()
+ connectionCount := len(m.messengers)
+ if connectionCount == 0 {
+ log.Trace("Event source has no listeners")
+ // empty the connection channel
+ select {
+ case <-m.connection:
+ default:
+ }
+ }
+ m.mutex.Unlock()
+ if connectionCount == 0 {
+ // No listeners so the source can be paused
+ log.Trace("Pausing the eventsource")
+ select {
+ case <-ctx.Done():
+ break loop
+ case <-m.connection:
+ log.Trace("Connection detected - restarting the eventsource")
+ // OK we're back so lets reset the timer and start again
+ // We won't change the "then" time because there could be concurrency issues
+ select {
+ case <-timer.C:
+ default:
+ }
+ continue
+ }
+ }
+
+ now := timeutil.TimeStampNow().Add(-2)
+
+ uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now)
+ if err != nil {
+ log.Error("Unable to get UIDcounts: %v", err)
+ }
+ for _, uidCount := range uidCounts {
+ m.SendMessage(uidCount.UserID, &Event{
+ Name: "notification-count",
+ Data: uidCount,
+ })
+ }
+ then = now
+
+ if setting.Service.EnableTimetracking {
+ usersStopwatches, err := issues_model.GetUIDsAndStopwatch(ctx)
+ if err != nil {
+ log.Error("Unable to get GetUIDsAndStopwatch: %v", err)
+ return
+ }
+
+ for _, userStopwatches := range usersStopwatches {
+ apiSWs, err := convert.ToStopWatches(ctx, userStopwatches.StopWatches)
+ if err != nil {
+ if !issues_model.IsErrIssueNotExist(err) {
+ log.Error("Unable to APIFormat stopwatches: %v", err)
+ }
+ continue
+ }
+ dataBs, err := json.Marshal(apiSWs)
+ if err != nil {
+ log.Error("Unable to marshal stopwatches: %v", err)
+ continue
+ }
+ m.SendMessage(userStopwatches.UserID, &Event{
+ Name: "stopwatches",
+ Data: string(dataBs),
+ })
+ }
+ }
+ }
+ }
+ m.UnregisterAll()
+}
diff --git a/modules/eventsource/messenger.go b/modules/eventsource/messenger.go
new file mode 100644
index 0000000..378e717
--- /dev/null
+++ b/modules/eventsource/messenger.go
@@ -0,0 +1,68 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eventsource
+
+import "sync"
+
+// Messenger is a per uid message store
+type Messenger struct {
+ mutex sync.Mutex
+ uid int64
+ channels []chan *Event
+}
+
+// NewMessenger creates a messenger for a particular uid
+func NewMessenger(uid int64) *Messenger {
+ return &Messenger{
+ uid: uid,
+ channels: [](chan *Event){},
+ }
+}
+
+// Register returns a new chan []byte
+func (m *Messenger) Register() <-chan *Event {
+ m.mutex.Lock()
+ // TODO: Limit the number of messengers per uid
+ channel := make(chan *Event, 1)
+ m.channels = append(m.channels, channel)
+ m.mutex.Unlock()
+ return channel
+}
+
+// Unregister removes the provider chan []byte
+func (m *Messenger) Unregister(channel <-chan *Event) bool {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for i, toRemove := range m.channels {
+ if channel == toRemove {
+ m.channels = append(m.channels[:i], m.channels[i+1:]...)
+ close(toRemove)
+ break
+ }
+ }
+ return len(m.channels) == 0
+}
+
+// UnregisterAll removes all chan []byte
+func (m *Messenger) UnregisterAll() {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for _, channel := range m.channels {
+ close(channel)
+ }
+ m.channels = nil
+}
+
+// SendMessage sends the message to all registered channels
+func (m *Messenger) SendMessage(message *Event) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for i := range m.channels {
+ channel := m.channels[i]
+ select {
+ case channel <- message:
+ default:
+ }
+ }
+}
diff --git a/modules/forgefed/activity.go b/modules/forgefed/activity.go
new file mode 100644
index 0000000..247abd2
--- /dev/null
+++ b/modules/forgefed/activity.go
@@ -0,0 +1,65 @@
+// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/validation"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+// ForgeLike activity data type
+// swagger:model
+type ForgeLike struct {
+ // swagger:ignore
+ ap.Activity
+}
+
+func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) {
+ result := ForgeLike{}
+ result.Type = ap.LikeType
+ result.Actor = ap.IRI(actorIRI) // That's us, a User
+ result.Object = ap.IRI(objectIRI) // That's them, a Repository
+ result.StartTime = startTime
+ if valid, err := validation.IsValid(result); !valid {
+ return ForgeLike{}, err
+ }
+ return result, nil
+}
+
+func (like ForgeLike) MarshalJSON() ([]byte, error) {
+ return like.Activity.MarshalJSON()
+}
+
+func (like *ForgeLike) UnmarshalJSON(data []byte) error {
+ return like.Activity.UnmarshalJSON(data)
+}
+
+func (like ForgeLike) IsNewer(compareTo time.Time) bool {
+ return like.StartTime.After(compareTo)
+}
+
+func (like ForgeLike) Validate() []string {
+ var result []string
+ result = append(result, validation.ValidateNotEmpty(string(like.Type), "type")...)
+ result = append(result, validation.ValidateOneOf(string(like.Type), []any{"Like"}, "type")...)
+ if like.Actor == nil {
+ result = append(result, "Actor should not be nil.")
+ } else {
+ result = append(result, validation.ValidateNotEmpty(like.Actor.GetID().String(), "actor")...)
+ }
+ if like.Object == nil {
+ result = append(result, "Object should not be nil.")
+ } else {
+ result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...)
+ }
+ result = append(result, validation.ValidateNotEmpty(like.StartTime.String(), "startTime")...)
+ if like.StartTime.IsZero() {
+ result = append(result, "StartTime was invalid.")
+ }
+
+ return result
+}
diff --git a/modules/forgefed/activity_test.go b/modules/forgefed/activity_test.go
new file mode 100644
index 0000000..9a7979c
--- /dev/null
+++ b/modules/forgefed/activity_test.go
@@ -0,0 +1,171 @@
+// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/validation"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+func Test_NewForgeLike(t *testing.T) {
+ actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"
+ objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1"
+ want := []byte(`{"type":"Like","startTime":"2024-03-27T00:00:00Z","actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1","object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}`)
+
+ startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27")
+ sut, err := NewForgeLike(actorIRI, objectIRI, startTime)
+ if err != nil {
+ t.Errorf("unexpected error: %v\n", err)
+ }
+ if valid, _ := validation.IsValid(sut); !valid {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+
+ got, err := sut.MarshalJSON()
+ if err != nil {
+ t.Errorf("MarshalJSON() error = \"%v\"", err)
+ return
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("MarshalJSON() got = %q, want %q", got, want)
+ }
+}
+
+func Test_LikeMarshalJSON(t *testing.T) {
+ type testPair struct {
+ item ForgeLike
+ want []byte
+ wantErr error
+ }
+
+ tests := map[string]testPair{
+ "empty": {
+ item: ForgeLike{},
+ want: nil,
+ },
+ "with ID": {
+ item: ForgeLike{
+ Activity: ap.Activity{
+ Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"),
+ Type: "Like",
+ Object: ap.IRI("https://codeberg.org/api/v1/activitypub/repository-id/1"),
+ },
+ },
+ want: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1","object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}`),
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := tt.item.MarshalJSON()
+ if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
+ t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_LikeUnmarshalJSON(t *testing.T) {
+ type testPair struct {
+ item []byte
+ want *ForgeLike
+ wantErr error
+ }
+
+ //revive:disable
+ tests := map[string]testPair{
+ "with ID": {
+ item: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"}`),
+ want: &ForgeLike{
+ Activity: ap.Activity{
+ Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"),
+ Type: "Like",
+ Object: ap.IRI("https://codeberg.org/api/activitypub/repository-id/1"),
+ },
+ },
+ wantErr: nil,
+ },
+ "invalid": {
+ item: []byte(`{"type":"Invalid","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"`),
+ want: &ForgeLike{},
+ wantErr: fmt.Errorf("cannot parse JSON:"),
+ },
+ }
+ //revive:enable
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := new(ForgeLike)
+ err := got.UnmarshalJSON(test.item)
+ if (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()) {
+ t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, test.want) {
+ t.Errorf("UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error())
+ }
+ })
+ }
+}
+
+func TestActivityValidation(t *testing.T) {
+ sut := new(ForgeLike)
+ sut.UnmarshalJSON([]byte(`{"type":"Like",
+ "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
+ "object":"https://codeberg.org/api/activitypub/repository-id/1",
+ "startTime": "2014-12-31T23:00:00-08:00"}`))
+ if res, _ := validation.IsValid(sut); !res {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+
+ sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
+ "object":"https://codeberg.org/api/activitypub/repository-id/1",
+ "startTime": "2014-12-31T23:00:00-08:00"}`))
+ if sut.Validate()[0] != "type should not be empty" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
+ }
+
+ sut.UnmarshalJSON([]byte(`{"type":"bad-type",
+ "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
+ "object":"https://codeberg.org/api/activitypub/repository-id/1",
+ "startTime": "2014-12-31T23:00:00-08:00"}`))
+ if sut.Validate()[0] != "Value bad-type is not contained in allowed values [Like]" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
+ }
+
+ sut.UnmarshalJSON([]byte(`{"type":"Like",
+ "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
+ "object":"https://codeberg.org/api/activitypub/repository-id/1",
+ "startTime": "not a date"}`))
+ if sut.Validate()[0] != "StartTime was invalid." {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate())
+ }
+
+ sut.UnmarshalJSON([]byte(`{"type":"Wrong",
+ "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
+ "object":"https://codeberg.org/api/activitypub/repository-id/1",
+ "startTime": "2014-12-31T23:00:00-08:00"}`))
+ if sut.Validate()[0] != "Value Wrong is not contained in allowed values [Like]" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate())
+ }
+}
+
+func TestActivityValidation_Attack(t *testing.T) {
+ sut := new(ForgeLike)
+ sut.UnmarshalJSON([]byte(`{rubbish}`))
+ if len(sut.Validate()) != 5 {
+ t.Errorf("5 validateion errors expected but was: %v\n", len(sut.Validate()))
+ }
+}
diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go
new file mode 100644
index 0000000..0ef4618
--- /dev/null
+++ b/modules/forgefed/actor.go
@@ -0,0 +1,218 @@
+// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/validation"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+// ----------------------------- ActorID --------------------------------------------
+type ActorID struct {
+ ID string
+ Source string
+ Schema string
+ Path string
+ Host string
+ Port string
+ UnvalidatedInput string
+}
+
+// Factory function for ActorID. Created struct is asserted to be valid
+func NewActorID(uri string) (ActorID, error) {
+ result, err := newActorID(uri)
+ if err != nil {
+ return ActorID{}, err
+ }
+
+ if valid, err := validation.IsValid(result); !valid {
+ return ActorID{}, err
+ }
+
+ return result, nil
+}
+
+func (id ActorID) AsURI() string {
+ var result string
+ if id.Port == "" {
+ result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID)
+ } else {
+ result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID)
+ }
+ return result
+}
+
+func (id ActorID) Validate() []string {
+ var result []string
+ result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...)
+ result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...)
+ result = append(result, validation.ValidateNotEmpty(id.Path, "path")...)
+ result = append(result, validation.ValidateNotEmpty(id.Host, "host")...)
+ result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...)
+
+ if id.UnvalidatedInput != id.AsURI() {
+ result = append(result, fmt.Sprintf("not all input was parsed, \nUnvalidated Input:%q \nParsed URI: %q", id.UnvalidatedInput, id.AsURI()))
+ }
+
+ return result
+}
+
+// ----------------------------- PersonID --------------------------------------------
+type PersonID struct {
+ ActorID
+}
+
+// Factory function for PersonID. Created struct is asserted to be valid
+func NewPersonID(uri, source string) (PersonID, error) {
+ result, err := newActorID(uri)
+ if err != nil {
+ return PersonID{}, err
+ }
+ result.Source = source
+
+ // validate Person specific path
+ personID := PersonID{result}
+ if valid, err := validation.IsValid(personID); !valid {
+ return PersonID{}, err
+ }
+
+ return personID, nil
+}
+
+func (id PersonID) AsWebfinger() string {
+ result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host))
+ return result
+}
+
+func (id PersonID) AsLoginName() string {
+ result := fmt.Sprintf("%s%s", strings.ToLower(id.ID), id.HostSuffix())
+ return result
+}
+
+func (id PersonID) HostSuffix() string {
+ result := fmt.Sprintf("-%s", strings.ToLower(id.Host))
+ return result
+}
+
+func (id PersonID) Validate() []string {
+ result := id.ActorID.Validate()
+ result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
+ result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
+ switch id.Source {
+ case "forgejo", "gitea":
+ if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" {
+ result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path))
+ }
+ }
+ return result
+}
+
+// ----------------------------- RepositoryID --------------------------------------------
+
+type RepositoryID struct {
+ ActorID
+}
+
+// Factory function for RepositoryID. Created struct is asserted to be valid.
+func NewRepositoryID(uri, source string) (RepositoryID, error) {
+ result, err := newActorID(uri)
+ if err != nil {
+ return RepositoryID{}, err
+ }
+ result.Source = source
+
+ // validate Person specific
+ repoID := RepositoryID{result}
+ if valid, err := validation.IsValid(repoID); !valid {
+ return RepositoryID{}, err
+ }
+
+ return repoID, nil
+}
+
+func (id RepositoryID) Validate() []string {
+ result := id.ActorID.Validate()
+ result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
+ result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
+ switch id.Source {
+ case "forgejo", "gitea":
+ if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" {
+ result = append(result, fmt.Sprintf("path: %q has to be a repo specific api path", id.Path))
+ }
+ }
+ return result
+}
+
+func containsEmptyString(ar []string) bool {
+ for _, elem := range ar {
+ if elem == "" {
+ return true
+ }
+ }
+ return false
+}
+
+func removeEmptyStrings(ls []string) []string {
+ var rs []string
+ for _, str := range ls {
+ if str != "" {
+ rs = append(rs, str)
+ }
+ }
+ return rs
+}
+
+func newActorID(uri string) (ActorID, error) {
+ validatedURI, err := url.ParseRequestURI(uri)
+ if err != nil {
+ return ActorID{}, err
+ }
+ pathWithActorID := strings.Split(validatedURI.Path, "/")
+ if containsEmptyString(pathWithActorID) {
+ pathWithActorID = removeEmptyStrings(pathWithActorID)
+ }
+ length := len(pathWithActorID)
+ pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/")
+ id := pathWithActorID[length-1]
+
+ result := ActorID{}
+ result.ID = id
+ result.Schema = validatedURI.Scheme
+ result.Host = validatedURI.Hostname()
+ result.Path = pathWithoutActorID
+ result.Port = validatedURI.Port()
+ result.UnvalidatedInput = uri
+ return result, nil
+}
+
+// ----------------------------- ForgePerson -------------------------------------
+
+// ForgePerson activity data type
+// swagger:model
+type ForgePerson struct {
+ // swagger:ignore
+ ap.Actor
+}
+
+func (s ForgePerson) MarshalJSON() ([]byte, error) {
+ return s.Actor.MarshalJSON()
+}
+
+func (s *ForgePerson) UnmarshalJSON(data []byte) error {
+ return s.Actor.UnmarshalJSON(data)
+}
+
+func (s ForgePerson) Validate() []string {
+ var result []string
+ result = append(result, validation.ValidateNotEmpty(string(s.Type), "Type")...)
+ result = append(result, validation.ValidateOneOf(string(s.Type), []any{string(ap.PersonType)}, "Type")...)
+ result = append(result, validation.ValidateNotEmpty(s.PreferredUsername.String(), "PreferredUsername")...)
+
+ return result
+}
diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go
new file mode 100644
index 0000000..a3c01ec
--- /dev/null
+++ b/modules/forgefed/actor_test.go
@@ -0,0 +1,225 @@
+// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/validation"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+func TestNewPersonId(t *testing.T) {
+ expected := PersonID{}
+ expected.ID = "1"
+ expected.Source = "forgejo"
+ expected.Schema = "https"
+ expected.Path = "api/v1/activitypub/user-id"
+ expected.Host = "an.other.host"
+ expected.Port = ""
+ expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
+ sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
+ if sut != expected {
+ t.Errorf("expected: %v\n but was: %v\n", expected, sut)
+ }
+
+ expected = PersonID{}
+ expected.ID = "1"
+ expected.Source = "forgejo"
+ expected.Schema = "https"
+ expected.Path = "api/v1/activitypub/user-id"
+ expected.Host = "an.other.host"
+ expected.Port = "443"
+ expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1"
+ sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo")
+ if sut != expected {
+ t.Errorf("expected: %v\n but was: %v\n", expected, sut)
+ }
+}
+
+func TestNewRepositoryId(t *testing.T) {
+ setting.AppURL = "http://localhost:3000/"
+ expected := RepositoryID{}
+ expected.ID = "1"
+ expected.Source = "forgejo"
+ expected.Schema = "http"
+ expected.Path = "api/activitypub/repository-id"
+ expected.Host = "localhost"
+ expected.Port = "3000"
+ expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1"
+ sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo")
+ if sut != expected {
+ t.Errorf("expected: %v\n but was: %v\n", expected, sut)
+ }
+}
+
+func TestActorIdValidation(t *testing.T) {
+ sut := ActorID{}
+ sut.Source = "forgejo"
+ sut.Schema = "https"
+ sut.Path = "api/v1/activitypub/user-id"
+ sut.Host = "an.other.host"
+ sut.Port = ""
+ sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/"
+ if sut.Validate()[0] != "userId should not be empty" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate())
+ }
+
+ sut = ActorID{}
+ sut.ID = "1"
+ sut.Source = "forgejo"
+ sut.Schema = "https"
+ sut.Path = "api/v1/activitypub/user-id"
+ sut.Host = "an.other.host"
+ sut.Port = ""
+ sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action"
+ if sut.Validate()[0] != "not all input was parsed, \nUnvalidated Input:\"https://an.other.host/api/v1/activitypub/user-id/1?illegal=action\" \nParsed URI: \"https://an.other.host/api/v1/activitypub/user-id/1\"" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
+ }
+}
+
+func TestPersonIdValidation(t *testing.T) {
+ sut := PersonID{}
+ sut.ID = "1"
+ sut.Source = "forgejo"
+ sut.Schema = "https"
+ sut.Path = "path"
+ sut.Host = "an.other.host"
+ sut.Port = ""
+ sut.UnvalidatedInput = "https://an.other.host/path/1"
+
+ _, err := validation.IsValid(sut)
+ if validation.IsErrNotValid(err) && strings.Contains(err.Error(), "path: \"path\" has to be a person specific api path\n") {
+ t.Errorf("validation error expected but was: %v\n", err)
+ }
+
+ sut = PersonID{}
+ sut.ID = "1"
+ sut.Source = "forgejox"
+ sut.Schema = "https"
+ sut.Path = "api/v1/activitypub/user-id"
+ sut.Host = "an.other.host"
+ sut.Port = ""
+ sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
+ if sut.Validate()[0] != "Value forgejox is not contained in allowed values [forgejo gitea]" {
+ t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
+ }
+}
+
+func TestWebfingerId(t *testing.T) {
+ sut, _ := NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
+ if sut.AsWebfinger() != "@12345@codeberg.org" {
+ t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
+ }
+
+ sut, _ = NewPersonID("https://Codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
+ if sut.AsWebfinger() != "@12345@codeberg.org" {
+ t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
+ }
+}
+
+func TestShouldThrowErrorOnInvalidInput(t *testing.T) {
+ var err any
+ // TODO: remove after test
+ //_, err = NewPersonId("", "forgejo")
+ //if err == nil {
+ // t.Errorf("empty input should be invalid.")
+ //}
+
+ _, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo")
+ if err == nil {
+ t.Errorf("localhost uris are not external")
+ }
+ _, err = NewPersonID("./api/v1/something", "forgejo")
+ if err == nil {
+ t.Errorf("relative uris are not allowed")
+ }
+ _, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo")
+ if err == nil {
+ t.Errorf("uri may not be ip-4 based")
+ }
+ _, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo")
+ if err == nil {
+ t.Errorf("uri may not be ip-6 based")
+ }
+ _, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo")
+ if err == nil {
+ t.Errorf("uri may not contain relative path elements")
+ }
+ _, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo")
+ if err == nil {
+ t.Errorf("uri may not contain unparsed elements")
+ }
+
+ _, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
+ if err != nil {
+ t.Errorf("this uri should be valid but was: %v", err)
+ }
+}
+
+func Test_PersonMarshalJSON(t *testing.T) {
+ sut := ForgePerson{}
+ sut.Type = "Person"
+ sut.PreferredUsername = ap.NaturalLanguageValuesNew()
+ sut.PreferredUsername.Set("en", ap.Content("MaxMuster"))
+ result, _ := sut.MarshalJSON()
+ if string(result) != "{\"type\":\"Person\",\"preferredUsername\":\"MaxMuster\"}" {
+ t.Errorf("MarshalJSON() was = %q", result)
+ }
+}
+
+func Test_PersonUnmarshalJSON(t *testing.T) {
+ expected := &ForgePerson{
+ Actor: ap.Actor{
+ Type: "Person",
+ PreferredUsername: ap.NaturalLanguageValues{
+ ap.LangRefValue{Ref: "en", Value: []byte("MaxMuster")},
+ },
+ },
+ }
+ sut := new(ForgePerson)
+ err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
+ if err != nil {
+ t.Errorf("UnmarshalJSON() unexpected error: %v", err)
+ }
+ x, _ := expected.MarshalJSON()
+ y, _ := sut.MarshalJSON()
+ if !reflect.DeepEqual(x, y) {
+ t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y)
+ }
+
+ expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{
+ "id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
+ "type":"Person",
+ "icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatar/fa7f9c4af2a64f41b1bef292bf872614"},
+ "url":"https://federated-repo.prod.meissa.de/stargoose9",
+ "inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/inbox",
+ "outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/outbox",
+ "preferredUsername":"stargoose9",
+ "publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10#main-key",
+ "owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
+ "publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBoj...XAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`,
+ "\n", ""),
+ "\t", "")
+ err = sut.UnmarshalJSON([]byte(expectedStr))
+ if err != nil {
+ t.Errorf("UnmarshalJSON() unexpected error: %v", err)
+ }
+ result, _ := sut.MarshalJSON()
+ if expectedStr != string(result) {
+ t.Errorf("UnmarshalJSON() expected: %q got: %q", expectedStr, result)
+ }
+}
+
+func TestForgePersonValidation(t *testing.T) {
+ sut := new(ForgePerson)
+ sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
+ if res, _ := validation.IsValid(sut); !res {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+}
diff --git a/modules/forgefed/forgefed.go b/modules/forgefed/forgefed.go
new file mode 100644
index 0000000..2344dc7
--- /dev/null
+++ b/modules/forgefed/forgefed.go
@@ -0,0 +1,52 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ ap "github.com/go-ap/activitypub"
+ "github.com/valyala/fastjson"
+)
+
+const ForgeFedNamespaceURI = "https://forgefed.org/ns"
+
+// GetItemByType instantiates a new ForgeFed object if the type matches
+// otherwise it defaults to existing activitypub package typer function.
+func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) {
+ switch typ {
+ case RepositoryType:
+ return RepositoryNew(""), nil
+ default:
+ return ap.GetItemByType(typ)
+ }
+}
+
+// JSONUnmarshalerFn is the function that will load the data from a fastjson.Value into an Item
+// that the go-ap/activitypub package doesn't know about.
+func JSONUnmarshalerFn(typ ap.ActivityVocabularyType, val *fastjson.Value, i ap.Item) error {
+ switch typ {
+ case RepositoryType:
+ return OnRepository(i, func(r *Repository) error {
+ return JSONLoadRepository(val, r)
+ })
+ default:
+ return nil
+ }
+}
+
+// NotEmpty is the function that checks if an object is empty
+func NotEmpty(i ap.Item) bool {
+ if ap.IsNil(i) {
+ return false
+ }
+ switch i.GetType() {
+ case RepositoryType:
+ r, err := ToRepository(i)
+ if err != nil {
+ return false
+ }
+ return ap.NotEmpty(r.Actor)
+ default:
+ return ap.NotEmpty(i)
+ }
+}
diff --git a/modules/forgefed/nodeinfo.go b/modules/forgefed/nodeinfo.go
new file mode 100644
index 0000000..b22d295
--- /dev/null
+++ b/modules/forgefed/nodeinfo.go
@@ -0,0 +1,19 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "fmt"
+)
+
+func (id ActorID) AsWellKnownNodeInfoURI() string {
+ wellKnownPath := ".well-known/nodeinfo"
+ var result string
+ if id.Port == "" {
+ result = fmt.Sprintf("%s://%s/%s", id.Schema, id.Host, wellKnownPath)
+ } else {
+ result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath)
+ }
+ return result
+}
diff --git a/modules/forgefed/repository.go b/modules/forgefed/repository.go
new file mode 100644
index 0000000..63680cc
--- /dev/null
+++ b/modules/forgefed/repository.go
@@ -0,0 +1,111 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "reflect"
+ "unsafe"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/valyala/fastjson"
+)
+
+const (
+ RepositoryType ap.ActivityVocabularyType = "Repository"
+)
+
+type Repository struct {
+ ap.Actor
+ // Team Collection of actors who have management/push access to the repository
+ Team ap.Item `jsonld:"team,omitempty"`
+ // Forks OrderedCollection of repositories that are forks of this repository
+ Forks ap.Item `jsonld:"forks,omitempty"`
+ // ForkedFrom Identifies the repository which this repository was created as a fork
+ ForkedFrom ap.Item `jsonld:"forkedFrom,omitempty"`
+}
+
+// RepositoryNew initializes a Repository type actor
+func RepositoryNew(id ap.ID) *Repository {
+ a := ap.ActorNew(id, RepositoryType)
+ a.Type = RepositoryType
+ o := Repository{Actor: *a}
+ return &o
+}
+
+func (r Repository) MarshalJSON() ([]byte, error) {
+ b, err := r.Actor.MarshalJSON()
+ if len(b) == 0 || err != nil {
+ return nil, err
+ }
+
+ b = b[:len(b)-1]
+ if r.Team != nil {
+ ap.JSONWriteItemProp(&b, "team", r.Team)
+ }
+ if r.Forks != nil {
+ ap.JSONWriteItemProp(&b, "forks", r.Forks)
+ }
+ if r.ForkedFrom != nil {
+ ap.JSONWriteItemProp(&b, "forkedFrom", r.ForkedFrom)
+ }
+ ap.JSONWrite(&b, '}')
+ return b, nil
+}
+
+func JSONLoadRepository(val *fastjson.Value, r *Repository) error {
+ if err := ap.OnActor(&r.Actor, func(a *ap.Actor) error {
+ return ap.JSONLoadActor(val, a)
+ }); err != nil {
+ return err
+ }
+
+ r.Team = ap.JSONGetItem(val, "team")
+ r.Forks = ap.JSONGetItem(val, "forks")
+ r.ForkedFrom = ap.JSONGetItem(val, "forkedFrom")
+ return nil
+}
+
+func (r *Repository) UnmarshalJSON(data []byte) error {
+ p := fastjson.Parser{}
+ val, err := p.ParseBytes(data)
+ if err != nil {
+ return err
+ }
+ return JSONLoadRepository(val, r)
+}
+
+// ToRepository tries to convert the it Item to a Repository Actor.
+func ToRepository(it ap.Item) (*Repository, error) {
+ switch i := it.(type) {
+ case *Repository:
+ return i, nil
+ case Repository:
+ return &i, nil
+ case *ap.Actor:
+ return (*Repository)(unsafe.Pointer(i)), nil
+ case ap.Actor:
+ return (*Repository)(unsafe.Pointer(&i)), nil
+ default:
+ // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
+ typ := reflect.TypeOf(new(Repository))
+ if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Repository); ok {
+ return i, nil
+ }
+ }
+ return nil, ap.ErrorInvalidType[ap.Actor](it)
+}
+
+type withRepositoryFn func(*Repository) error
+
+// OnRepository calls function fn on it Item if it can be asserted to type *Repository
+func OnRepository(it ap.Item, fn withRepositoryFn) error {
+ if it == nil {
+ return nil
+ }
+ ob, err := ToRepository(it)
+ if err != nil {
+ return err
+ }
+ return fn(ob)
+}
diff --git a/modules/forgefed/repository_test.go b/modules/forgefed/repository_test.go
new file mode 100644
index 0000000..13a73c1
--- /dev/null
+++ b/modules/forgefed/repository_test.go
@@ -0,0 +1,145 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgefed
+
+import (
+ "fmt"
+ "reflect"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+func Test_RepositoryMarshalJSON(t *testing.T) {
+ type testPair struct {
+ item Repository
+ want []byte
+ wantErr error
+ }
+
+ tests := map[string]testPair{
+ "empty": {
+ item: Repository{},
+ want: nil,
+ },
+ "with ID": {
+ item: Repository{
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ Team: nil,
+ },
+ want: []byte(`{"id":"https://example.com/1"}`),
+ },
+ "with Team as IRI": {
+ item: Repository{
+ Team: ap.IRI("https://example.com/1"),
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ },
+ want: []byte(`{"id":"https://example.com/1","team":"https://example.com/1"}`),
+ },
+ "with Team as IRIs": {
+ item: Repository{
+ Team: ap.ItemCollection{
+ ap.IRI("https://example.com/1"),
+ ap.IRI("https://example.com/2"),
+ },
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ },
+ want: []byte(`{"id":"https://example.com/1","team":["https://example.com/1","https://example.com/2"]}`),
+ },
+ "with Team as Object": {
+ item: Repository{
+ Team: ap.Object{ID: "https://example.com/1"},
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ },
+ want: []byte(`{"id":"https://example.com/1","team":{"id":"https://example.com/1"}}`),
+ },
+ "with Team as slice of Objects": {
+ item: Repository{
+ Team: ap.ItemCollection{
+ ap.Object{ID: "https://example.com/1"},
+ ap.Object{ID: "https://example.com/2"},
+ },
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ },
+ want: []byte(`{"id":"https://example.com/1","team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`),
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := tt.item.MarshalJSON()
+ if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
+ t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_RepositoryUnmarshalJSON(t *testing.T) {
+ type testPair struct {
+ data []byte
+ want *Repository
+ wantErr error
+ }
+
+ tests := map[string]testPair{
+ "nil": {
+ data: nil,
+ wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
+ },
+ "empty": {
+ data: []byte{},
+ wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
+ },
+ "with Type": {
+ data: []byte(`{"type":"Repository"}`),
+ want: &Repository{
+ Actor: ap.Actor{
+ Type: RepositoryType,
+ },
+ },
+ },
+ "with Type and ID": {
+ data: []byte(`{"id":"https://example.com/1","type":"Repository"}`),
+ want: &Repository{
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ Type: RepositoryType,
+ },
+ },
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := new(Repository)
+ err := got.UnmarshalJSON(tt.data)
+ if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
+ t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
+ return
+ }
+ if tt.want != nil && !reflect.DeepEqual(got, tt.want) {
+ jGot, _ := json.Marshal(got)
+ jWant, _ := json.Marshal(tt.want)
+ t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant)
+ }
+ })
+ }
+}
diff --git a/modules/generate/generate.go b/modules/generate/generate.go
new file mode 100644
index 0000000..41a6aa2
--- /dev/null
+++ b/modules/generate/generate.go
@@ -0,0 +1,75 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package generate
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "time"
+
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
+func NewInternalToken() (string, error) {
+ secretBytes := make([]byte, 32)
+ _, err := io.ReadFull(rand.Reader, secretBytes)
+ if err != nil {
+ return "", err
+ }
+
+ secretKey := base64.RawURLEncoding.EncodeToString(secretBytes)
+
+ now := time.Now()
+
+ var internalToken string
+ internalToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+ "nbf": now.Unix(),
+ }).SignedString([]byte(secretKey))
+ if err != nil {
+ return "", err
+ }
+
+ return internalToken, nil
+}
+
+const defaultJwtSecretLen = 32
+
+// DecodeJwtSecret decodes a base64 encoded jwt secret into bytes, and check its length
+func DecodeJwtSecret(src string) ([]byte, error) {
+ encoding := base64.RawURLEncoding
+ decoded := make([]byte, encoding.DecodedLen(len(src))+3)
+ if n, err := encoding.Decode(decoded, []byte(src)); err != nil {
+ return nil, err
+ } else if n != defaultJwtSecretLen {
+ return nil, fmt.Errorf("invalid base64 decoded length: %d, expects: %d", n, defaultJwtSecretLen)
+ }
+ return decoded[:defaultJwtSecretLen], nil
+}
+
+// NewJwtSecret generates a new base64 encoded value intended to be used for JWT secrets.
+func NewJwtSecret() ([]byte, string, error) {
+ bytes := make([]byte, 32)
+ _, err := rand.Read(bytes)
+ if err != nil {
+ return nil, "", err
+ }
+
+ return bytes, base64.RawURLEncoding.EncodeToString(bytes), nil
+}
+
+// NewSecretKey generate a new value intended to be used by SECRET_KEY.
+func NewSecretKey() (string, error) {
+ secretKey, err := util.CryptoRandomString(64)
+ if err != nil {
+ return "", err
+ }
+
+ return secretKey, nil
+}
diff --git a/modules/generate/generate_test.go b/modules/generate/generate_test.go
new file mode 100644
index 0000000..eb7178a
--- /dev/null
+++ b/modules/generate/generate_test.go
@@ -0,0 +1,35 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package generate
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodeJwtSecret(t *testing.T) {
+ _, err := DecodeJwtSecret("abcd")
+ require.ErrorContains(t, err, "invalid base64 decoded length")
+ _, err = DecodeJwtSecret(strings.Repeat("a", 64))
+ require.ErrorContains(t, err, "invalid base64 decoded length")
+
+ str32 := strings.Repeat("x", 32)
+ encoded32 := base64.RawURLEncoding.EncodeToString([]byte(str32))
+ decoded32, err := DecodeJwtSecret(encoded32)
+ require.NoError(t, err)
+ assert.Equal(t, str32, string(decoded32))
+}
+
+func TestNewJwtSecret(t *testing.T) {
+ secret, encoded, err := NewJwtSecret()
+ require.NoError(t, err)
+ assert.Len(t, secret, 32)
+ decoded, err := DecodeJwtSecret(encoded)
+ require.NoError(t, err)
+ assert.Equal(t, secret, decoded)
+}
diff --git a/modules/git/README.md b/modules/git/README.md
new file mode 100644
index 0000000..4418c1b
--- /dev/null
+++ b/modules/git/README.md
@@ -0,0 +1,3 @@
+# Git Module
+
+This module is merged from https://github.com/go-gitea/git which is a Go module to access Git through shell commands. Now it's a part of gitea's main repository for easier pull request.
diff --git a/modules/git/batch.go b/modules/git/batch.go
new file mode 100644
index 0000000..3ec4f1d
--- /dev/null
+++ b/modules/git/batch.go
@@ -0,0 +1,46 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+)
+
+type Batch struct {
+ cancel context.CancelFunc
+ Reader *bufio.Reader
+ Writer WriteCloserError
+}
+
+func (repo *Repository) NewBatch(ctx context.Context) (*Batch, error) {
+ // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
+ if err := ensureValidGitRepository(ctx, repo.Path); err != nil {
+ return nil, err
+ }
+
+ var batch Batch
+ batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repo.Path)
+ return &batch, nil
+}
+
+func (repo *Repository) NewBatchCheck(ctx context.Context) (*Batch, error) {
+ // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
+ if err := ensureValidGitRepository(ctx, repo.Path); err != nil {
+ return nil, err
+ }
+
+ var check Batch
+ check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repo.Path)
+ return &check, nil
+}
+
+func (b *Batch) Close() {
+ if b.cancel != nil {
+ b.cancel()
+ b.Reader = nil
+ b.Writer = nil
+ b.cancel = nil
+ }
+}
diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go
new file mode 100644
index 0000000..3b1a466
--- /dev/null
+++ b/modules/git/batch_reader.go
@@ -0,0 +1,347 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "math"
+ "runtime"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/djherbis/buffer"
+ "github.com/djherbis/nio/v3"
+)
+
+// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function
+type WriteCloserError interface {
+ io.WriteCloser
+ CloseWithError(err error) error
+}
+
+// ensureValidGitRepository runs git rev-parse in the repository path - thus ensuring that the repository is a valid repository.
+// Run before opening git cat-file.
+// This is needed otherwise the git cat-file will hang for invalid repositories.
+func ensureValidGitRepository(ctx context.Context, repoPath string) error {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "rev-parse").
+ SetDescription(fmt.Sprintf("%s rev-parse [repo_path: %s]", GitExecutable, repoPath)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ return ConcatenateError(err, (&stderr).String())
+ }
+ return nil
+}
+
+// catFileBatchCheck opens git cat-file --batch-check in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func catFileBatchCheck(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
+ batchStdinReader, batchStdinWriter := io.Pipe()
+ batchStdoutReader, batchStdoutWriter := io.Pipe()
+ ctx, ctxCancel := context.WithCancel(ctx)
+ closed := make(chan struct{})
+ cancel := func() {
+ ctxCancel()
+ _ = batchStdoutReader.Close()
+ _ = batchStdinWriter.Close()
+ <-closed
+ }
+
+ // Ensure cancel is called as soon as the provided context is cancelled
+ go func() {
+ <-ctx.Done()
+ cancel()
+ }()
+
+ _, filename, line, _ := runtime.Caller(2)
+ filename = strings.TrimPrefix(filename, callerPrefix)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "cat-file", "--batch-check").
+ SetDescription(fmt.Sprintf("%s cat-file --batch-check [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stdin: batchStdinReader,
+ Stdout: batchStdoutWriter,
+ Stderr: &stderr,
+
+ UseContextTimeout: true,
+ })
+ if err != nil {
+ _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ _ = batchStdinReader.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = batchStdoutWriter.Close()
+ _ = batchStdinReader.Close()
+ }
+ close(closed)
+ }()
+
+ // For simplicities sake we'll use a buffered reader to read from the cat-file --batch-check
+ batchReader := bufio.NewReader(batchStdoutReader)
+
+ return batchStdinWriter, batchReader, cancel
+}
+
+// catFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func catFileBatch(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
+ // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinReader, batchStdinWriter := io.Pipe()
+ batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(32 * 1024))
+ ctx, ctxCancel := context.WithCancel(ctx)
+ closed := make(chan struct{})
+ cancel := func() {
+ ctxCancel()
+ _ = batchStdinWriter.Close()
+ _ = batchStdoutReader.Close()
+ <-closed
+ }
+
+ // Ensure cancel is called as soon as the provided context is cancelled
+ go func() {
+ <-ctx.Done()
+ cancel()
+ }()
+
+ _, filename, line, _ := runtime.Caller(2)
+ filename = strings.TrimPrefix(filename, callerPrefix)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "cat-file", "--batch").
+ SetDescription(fmt.Sprintf("%s cat-file --batch [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stdin: batchStdinReader,
+ Stdout: batchStdoutWriter,
+ Stderr: &stderr,
+
+ UseContextTimeout: true,
+ })
+ if err != nil {
+ _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ _ = batchStdinReader.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = batchStdoutWriter.Close()
+ _ = batchStdinReader.Close()
+ }
+ close(closed)
+ }()
+
+ // For simplicities sake we'll us a buffered reader to read from the cat-file --batch
+ batchReader := bufio.NewReaderSize(batchStdoutReader, 32*1024)
+
+ return batchStdinWriter, batchReader, cancel
+}
+
+// ReadBatchLine reads the header line from cat-file --batch
+// We expect:
+// <sha> SP <type> SP <size> LF
+// sha is a hex encoded here
+func ReadBatchLine(rd *bufio.Reader) (sha []byte, typ string, size int64, err error) {
+ typ, err = rd.ReadString('\n')
+ if err != nil {
+ return sha, typ, size, err
+ }
+ if len(typ) == 1 {
+ typ, err = rd.ReadString('\n')
+ if err != nil {
+ return sha, typ, size, err
+ }
+ }
+ idx := strings.IndexByte(typ, ' ')
+ if idx < 0 {
+ log.Debug("missing space typ: %s", typ)
+ return sha, typ, size, ErrNotExist{ID: string(sha)}
+ }
+ sha = []byte(typ[:idx])
+ typ = typ[idx+1:]
+
+ idx = strings.IndexByte(typ, ' ')
+ if idx < 0 {
+ return sha, typ, size, ErrNotExist{ID: string(sha)}
+ }
+
+ sizeStr := typ[idx+1 : len(typ)-1]
+ typ = typ[:idx]
+
+ size, err = strconv.ParseInt(sizeStr, 10, 64)
+ return sha, typ, size, err
+}
+
+// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream.
+func ReadTagObjectID(rd *bufio.Reader, size int64) (string, error) {
+ var id string
+ var n int64
+headerLoop:
+ for {
+ line, err := rd.ReadBytes('\n')
+ if err != nil {
+ return "", err
+ }
+ n += int64(len(line))
+ idx := bytes.Index(line, []byte{' '})
+ if idx < 0 {
+ continue
+ }
+
+ if string(line[:idx]) == "object" {
+ id = string(line[idx+1 : len(line)-1])
+ break headerLoop
+ }
+ }
+
+ // Discard the rest of the tag
+ return id, DiscardFull(rd, size-n+1)
+}
+
+// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream.
+func ReadTreeID(rd *bufio.Reader, size int64) (string, error) {
+ var id string
+ var n int64
+headerLoop:
+ for {
+ line, err := rd.ReadBytes('\n')
+ if err != nil {
+ return "", err
+ }
+ n += int64(len(line))
+ idx := bytes.Index(line, []byte{' '})
+ if idx < 0 {
+ continue
+ }
+
+ if string(line[:idx]) == "tree" {
+ id = string(line[idx+1 : len(line)-1])
+ break headerLoop
+ }
+ }
+
+ // Discard the rest of the commit
+ return id, DiscardFull(rd, size-n+1)
+}
+
+// git tree files are a list:
+// <mode-in-ascii> SP <fname> NUL <binary Hash>
+//
+// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools
+// Therefore we need some method to convert these binary hashes to hex hashes
+
+// constant hextable to help quickly convert between binary and hex representation
+const hextable = "0123456789abcdef"
+
+// BinToHexHeash converts a binary Hash into a hex encoded one. Input and output can be the
+// same byte slice to support in place conversion without allocations.
+// This is at least 100x quicker that hex.EncodeToString
+func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte {
+ for i := objectFormat.FullLength()/2 - 1; i >= 0; i-- {
+ v := sha[i]
+ vhi, vlo := v>>4, v&0x0f
+ shi, slo := hextable[vhi], hextable[vlo]
+ out[i*2], out[i*2+1] = shi, slo
+ }
+ return out
+}
+
+// ParseTreeLine reads an entry from a tree in a cat-file --batch stream
+// This carefully avoids allocations - except where fnameBuf is too small.
+// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations
+//
+// Each line is composed of:
+// <mode-in-ascii-dropping-initial-zeros> SP <fname> NUL <binary HASH>
+//
+// We don't attempt to convert the raw HASH to save a lot of time
+func ParseTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) {
+ var readBytes []byte
+
+ // Read the Mode & fname
+ readBytes, err = rd.ReadSlice('\x00')
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ idx := bytes.IndexByte(readBytes, ' ')
+ if idx < 0 {
+ log.Debug("missing space in readBytes ParseTreeLine: %s", readBytes)
+ return mode, fname, sha, n, &ErrNotExist{}
+ }
+
+ n += idx + 1
+ copy(modeBuf, readBytes[:idx])
+ if len(modeBuf) >= idx {
+ modeBuf = modeBuf[:idx]
+ } else {
+ modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...)
+ }
+ mode = modeBuf
+
+ readBytes = readBytes[idx+1:]
+
+ // Deal with the fname
+ copy(fnameBuf, readBytes)
+ if len(fnameBuf) > len(readBytes) {
+ fnameBuf = fnameBuf[:len(readBytes)]
+ } else {
+ fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...)
+ }
+ for err == bufio.ErrBufferFull {
+ readBytes, err = rd.ReadSlice('\x00')
+ fnameBuf = append(fnameBuf, readBytes...)
+ }
+ n += len(fnameBuf)
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ fnameBuf = fnameBuf[:len(fnameBuf)-1]
+ fname = fnameBuf
+
+ // Deal with the binary hash
+ idx = 0
+ length := objectFormat.FullLength() / 2
+ for idx < length {
+ var read int
+ read, err = rd.Read(shaBuf[idx:length])
+ n += read
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ idx += read
+ }
+ sha = shaBuf
+ return mode, fname, sha, n, err
+}
+
+var callerPrefix string
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ callerPrefix = strings.TrimSuffix(filename, "modules/git/batch_reader.go")
+}
+
+func DiscardFull(rd *bufio.Reader, discard int64) error {
+ if discard > math.MaxInt32 {
+ n, err := rd.Discard(math.MaxInt32)
+ discard -= int64(n)
+ if err != nil {
+ return err
+ }
+ }
+ for discard > 0 {
+ n, err := rd.Discard(int(discard))
+ discard -= int64(n)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/modules/git/blame.go b/modules/git/blame.go
new file mode 100644
index 0000000..69e1b08
--- /dev/null
+++ b/modules/git/blame.go
@@ -0,0 +1,211 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// BlamePart represents block of blame - continuous lines with one sha
+type BlamePart struct {
+ Sha string
+ Lines []string
+ PreviousSha string
+ PreviousPath string
+}
+
+// BlameReader returns part of file blame one by one
+type BlameReader struct {
+ output io.WriteCloser
+ reader io.ReadCloser
+ bufferedReader *bufio.Reader
+ done chan error
+ lastSha *string
+ ignoreRevsFile *string
+ objectFormat ObjectFormat
+}
+
+func (r *BlameReader) UsesIgnoreRevs() bool {
+ return r.ignoreRevsFile != nil
+}
+
+// NextPart returns next part of blame (sequential code lines with the same commit)
+func (r *BlameReader) NextPart() (*BlamePart, error) {
+ var blamePart *BlamePart
+
+ if r.lastSha != nil {
+ blamePart = &BlamePart{
+ Sha: *r.lastSha,
+ Lines: make([]string, 0),
+ }
+ }
+
+ const previousHeader = "previous "
+ var lineBytes []byte
+ var isPrefix bool
+ var err error
+
+ for err != io.EOF {
+ lineBytes, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+
+ if len(lineBytes) == 0 {
+ // isPrefix will be false
+ continue
+ }
+
+ var objectID string
+ objectFormatLength := r.objectFormat.FullLength()
+
+ if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) {
+ objectID = string(lineBytes[0:objectFormatLength])
+ }
+ if len(objectID) > 0 {
+ if blamePart == nil {
+ blamePart = &BlamePart{
+ Sha: objectID,
+ Lines: make([]string, 0),
+ }
+ }
+
+ if blamePart.Sha != objectID {
+ r.lastSha = &objectID
+ // need to munch to end of line...
+ for isPrefix {
+ _, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+ }
+ return blamePart, nil
+ }
+ } else if lineBytes[0] == '\t' {
+ blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:]))
+ } else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) {
+ offset := len(previousHeader) // already includes a space
+ blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength])
+ offset += objectFormatLength + 1 // +1 for space
+ blamePart.PreviousPath = string(lineBytes[offset:])
+ }
+
+ // need to munch to end of line...
+ for isPrefix {
+ _, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+ }
+ }
+
+ r.lastSha = nil
+
+ return blamePart, nil
+}
+
+// Close BlameReader - don't run NextPart after invoking that
+func (r *BlameReader) Close() error {
+ if r.bufferedReader == nil {
+ return nil
+ }
+
+ err := <-r.done
+ r.bufferedReader = nil
+ _ = r.reader.Close()
+ _ = r.output.Close()
+ if r.ignoreRevsFile != nil {
+ _ = util.Remove(*r.ignoreRevsFile)
+ }
+ return err
+}
+
+// CreateBlameReader creates reader for given repository, commit and file
+func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
+ var ignoreRevsFile *string
+ if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore {
+ ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
+ }
+
+ cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain")
+ if ignoreRevsFile != nil {
+ // Possible improvement: use --ignore-revs-file /dev/stdin on unix
+ // There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
+ cmd.AddOptionValues("--ignore-revs-file", *ignoreRevsFile)
+ }
+ cmd.AddDynamicArguments(commit.ID.String()).
+ AddDashesAndList(file).
+ SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath))
+ reader, stdout, err := os.Pipe()
+ if err != nil {
+ if ignoreRevsFile != nil {
+ _ = util.Remove(*ignoreRevsFile)
+ }
+ return nil, err
+ }
+
+ done := make(chan error, 1)
+
+ go func() {
+ stderr := bytes.Buffer{}
+ // TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
+ err := cmd.Run(&RunOpts{
+ UseContextTimeout: true,
+ Dir: repoPath,
+ Stdout: stdout,
+ Stderr: &stderr,
+ })
+ done <- err
+ _ = stdout.Close()
+ if err != nil {
+ log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
+ }
+ }()
+
+ bufferedReader := bufio.NewReader(reader)
+
+ return &BlameReader{
+ output: stdout,
+ reader: reader,
+ bufferedReader: bufferedReader,
+ done: done,
+ ignoreRevsFile: ignoreRevsFile,
+ objectFormat: objectFormat,
+ }, nil
+}
+
+func tryCreateBlameIgnoreRevsFile(commit *Commit) *string {
+ entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
+ if err != nil {
+ return nil
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil
+ }
+ defer r.Close()
+
+ f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs")
+ if err != nil {
+ return nil
+ }
+
+ _, err = io.Copy(f, r)
+ _ = f.Close()
+ if err != nil {
+ _ = util.Remove(f.Name())
+ return nil
+ }
+
+ return util.ToPointer(f.Name())
+}
diff --git a/modules/git/blame_sha256_test.go b/modules/git/blame_sha256_test.go
new file mode 100644
index 0000000..eeeeb9f
--- /dev/null
+++ b/modules/git/blame_sha256_test.go
@@ -0,0 +1,148 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingBlameOutputSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345")
+ require.NoError(t, err)
+
+ parts := []*BlamePart{
+ {
+ Sha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
+ Lines: []string{
+ "# test_repo",
+ "Test repository for testing migration from github to gitea",
+ },
+ },
+ {
+ Sha: "0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345",
+ Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
+ PreviousSha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
+ PreviousPath: "README.md",
+ },
+ }
+
+ for _, bypass := range []bool{false, true} {
+ blameReader, err := CreateBlameReader(ctx, Sha256ObjectFormat, "./tests/repos/repo5_pulls_sha256", commit, "README.md", bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.False(t, blameReader.UsesIgnoreRevs())
+
+ for _, part := range parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+
+ t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ full := []*BlamePart{
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line"},
+ },
+ {
+ Sha: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ Lines: []string{"changed line"},
+ PreviousSha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ PreviousPath: "blame.txt",
+ },
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line", ""},
+ },
+ }
+
+ cases := []struct {
+ CommitID string
+ UsesIgnoreRevs bool
+ Bypass bool
+ Parts []*BlamePart
+ }{
+ {
+ CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
+ UsesIgnoreRevs: true,
+ Bypass: false,
+ Parts: []*BlamePart{
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line", "changed line", "line", "line", ""},
+ },
+ },
+ },
+ {
+ CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
+ UsesIgnoreRevs: false,
+ Bypass: true,
+ Parts: full,
+ },
+ {
+ CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ {
+ CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ }
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+ for _, c := range cases {
+ commit, err := repo.GetCommit(c.CommitID)
+ require.NoError(t, err)
+ blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", commit, "blame.txt", c.Bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
+
+ for _, part := range c.Parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+}
diff --git a/modules/git/blame_test.go b/modules/git/blame_test.go
new file mode 100644
index 0000000..65320c7
--- /dev/null
+++ b/modules/git/blame_test.go
@@ -0,0 +1,147 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingBlameOutput(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2")
+ require.NoError(t, err)
+
+ parts := []*BlamePart{
+ {
+ Sha: "72866af952e98d02a73003501836074b286a78f6",
+ Lines: []string{
+ "# test_repo",
+ "Test repository for testing migration from github to gitea",
+ },
+ },
+ {
+ Sha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+ Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
+ PreviousSha: "72866af952e98d02a73003501836074b286a78f6",
+ PreviousPath: "README.md",
+ },
+ }
+
+ for _, bypass := range []bool{false, true} {
+ blameReader, err := CreateBlameReader(ctx, Sha1ObjectFormat, "./tests/repos/repo5_pulls", commit, "README.md", bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.False(t, blameReader.UsesIgnoreRevs())
+
+ for _, part := range parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+
+ t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ full := []*BlamePart{
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line"},
+ },
+ {
+ Sha: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ Lines: []string{"changed line"},
+ PreviousSha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ PreviousPath: "blame.txt",
+ },
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line", ""},
+ },
+ }
+
+ cases := []struct {
+ CommitID string
+ UsesIgnoreRevs bool
+ Bypass bool
+ Parts []*BlamePart
+ }{
+ {
+ CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
+ UsesIgnoreRevs: true,
+ Bypass: false,
+ Parts: []*BlamePart{
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line", "changed line", "line", "line", ""},
+ },
+ },
+ },
+ {
+ CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
+ UsesIgnoreRevs: false,
+ Bypass: true,
+ Parts: full,
+ },
+ {
+ CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ {
+ CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ }
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+ for _, c := range cases {
+ commit, err := repo.GetCommit(c.CommitID)
+ require.NoError(t, err)
+
+ blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
+
+ for _, part := range c.Parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+}
diff --git a/modules/git/blob.go b/modules/git/blob.go
new file mode 100644
index 0000000..2f02693
--- /dev/null
+++ b/modules/git/blob.go
@@ -0,0 +1,228 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "io"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Blob represents a Git object.
+type Blob struct {
+ ID ObjectID
+
+ gotSize bool
+ size int64
+ name string
+ repo *Repository
+}
+
+// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
+// Calling the Close function on the result will discard all unread output.
+func (b *Blob) DataAsync() (io.ReadCloser, error) {
+ wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = wr.Write([]byte(b.ID.String() + "\n"))
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ _, _, size, err := ReadBatchLine(rd)
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ b.gotSize = true
+ b.size = size
+
+ if size < 4096 {
+ bs, err := io.ReadAll(io.LimitReader(rd, size))
+ defer cancel()
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ return io.NopCloser(bytes.NewReader(bs)), err
+ }
+
+ return &blobReader{
+ rd: rd,
+ n: size,
+ cancel: cancel,
+ }, nil
+}
+
+// Size returns the uncompressed size of the blob
+func (b *Blob) Size() int64 {
+ if b.gotSize {
+ return b.size
+ }
+
+ wr, rd, cancel, err := b.repo.CatFileBatchCheck(b.repo.Ctx)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(b.ID.String() + "\n"))
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+ _, _, b.size, err = ReadBatchLine(rd)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+
+ b.gotSize = true
+
+ return b.size
+}
+
+type blobReader struct {
+ rd *bufio.Reader
+ n int64
+ cancel func()
+}
+
+func (b *blobReader) Read(p []byte) (n int, err error) {
+ if b.n <= 0 {
+ return 0, io.EOF
+ }
+ if int64(len(p)) > b.n {
+ p = p[0:b.n]
+ }
+ n, err = b.rd.Read(p)
+ b.n -= int64(n)
+ return n, err
+}
+
+// Close implements io.Closer
+func (b *blobReader) Close() error {
+ if b.rd == nil {
+ return nil
+ }
+
+ defer b.cancel()
+
+ if err := DiscardFull(b.rd, b.n+1); err != nil {
+ return err
+ }
+
+ b.rd = nil
+
+ return nil
+}
+
+// Name returns name of the tree entry this blob object was created from (or empty string)
+func (b *Blob) Name() string {
+ return b.name
+}
+
+// GetBlobContent Gets the limited content of the blob as raw text
+func (b *Blob) GetBlobContent(limit int64) (string, error) {
+ if limit <= 0 {
+ return "", nil
+ }
+ dataRc, err := b.DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer dataRc.Close()
+ buf, err := util.ReadWithLimit(dataRc, int(limit))
+ return string(buf), err
+}
+
+// GetBlobLineCount gets line count of the blob
+func (b *Blob) GetBlobLineCount() (int, error) {
+ reader, err := b.DataAsync()
+ if err != nil {
+ return 0, err
+ }
+ defer reader.Close()
+ buf := make([]byte, 32*1024)
+ count := 1
+ lineSep := []byte{'\n'}
+
+ c, err := reader.Read(buf)
+ if c == 0 && err == io.EOF {
+ return 0, nil
+ }
+ for {
+ count += bytes.Count(buf[:c], lineSep)
+ switch {
+ case err == io.EOF:
+ return count, nil
+ case err != nil:
+ return count, err
+ }
+ c, err = reader.Read(buf)
+ }
+}
+
+// GetBlobContentBase64 Reads the content of the blob with a base64 encode and returns the encoded string
+func (b *Blob) GetBlobContentBase64() (string, error) {
+ dataRc, err := b.DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer dataRc.Close()
+
+ pr, pw := io.Pipe()
+ encoder := base64.NewEncoder(base64.StdEncoding, pw)
+
+ go func() {
+ _, err := io.Copy(encoder, dataRc)
+ _ = encoder.Close()
+
+ if err != nil {
+ _ = pw.CloseWithError(err)
+ } else {
+ _ = pw.Close()
+ }
+ }()
+
+ out, err := io.ReadAll(pr)
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
+
+// GuessContentType guesses the content type of the blob.
+func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) {
+ r, err := b.DataAsync()
+ if err != nil {
+ return typesniffer.SniffedType{}, err
+ }
+ defer r.Close()
+
+ return typesniffer.DetectContentTypeFromReader(r)
+}
+
+// GetBlob finds the blob object in the repository.
+func (repo *Repository) GetBlob(idStr string) (*Blob, error) {
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+ if id.IsZero() {
+ return nil, ErrNotExist{id.String(), ""}
+ }
+ return &Blob{
+ ID: id,
+ repo: repo,
+ }, nil
+}
diff --git a/modules/git/blob_test.go b/modules/git/blob_test.go
new file mode 100644
index 0000000..810964b
--- /dev/null
+++ b/modules/git/blob_test.go
@@ -0,0 +1,59 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBlob_Data(t *testing.T) {
+ output := "file2\n"
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+
+ defer repo.Close()
+
+ testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
+ require.NoError(t, err)
+
+ r, err := testBlob.DataAsync()
+ require.NoError(t, err)
+ require.NotNil(t, r)
+
+ data, err := io.ReadAll(r)
+ require.NoError(t, r.Close())
+
+ require.NoError(t, err)
+ assert.Equal(t, output, string(data))
+}
+
+func Benchmark_Blob_Data(b *testing.B) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer repo.Close()
+
+ testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ for i := 0; i < b.N; i++ {
+ r, err := testBlob.DataAsync()
+ if err != nil {
+ b.Fatal(err)
+ }
+ io.ReadAll(r)
+ _ = r.Close()
+ }
+}
diff --git a/modules/git/command.go b/modules/git/command.go
new file mode 100644
index 0000000..a3d43aa
--- /dev/null
+++ b/modules/git/command.go
@@ -0,0 +1,473 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// TrustedCmdArgs returns the trusted arguments for git command.
+// It's mainly for passing user-provided and trusted arguments to git command
+// In most cases, it shouldn't be used. Use AddXxx function instead
+type TrustedCmdArgs []internal.CmdArg
+
+var (
+ // globalCommandArgs global command args for external package setting
+ globalCommandArgs TrustedCmdArgs
+
+ // defaultCommandExecutionTimeout default command execution timeout duration
+ defaultCommandExecutionTimeout = 360 * time.Second
+)
+
+// DefaultLocale is the default LC_ALL to run git commands in.
+const DefaultLocale = "C"
+
+// Command represents a command with its subcommands or arguments.
+type Command struct {
+ prog string
+ args []string
+ parentContext context.Context
+ desc string
+ globalArgsLength int
+ brokenArgs []string
+}
+
+func (c *Command) String() string {
+ return c.toString(false)
+}
+
+func (c *Command) toString(sanitizing bool) string {
+ // WARNING: this function is for debugging purposes only. It's much better than old code (which only joins args with space),
+ // It's impossible to make a simple and 100% correct implementation of argument quoting for different platforms.
+ debugQuote := func(s string) string {
+ if strings.ContainsAny(s, " `'\"\t\r\n") {
+ return fmt.Sprintf("%q", s)
+ }
+ return s
+ }
+ a := make([]string, 0, len(c.args)+1)
+ a = append(a, debugQuote(c.prog))
+ for _, arg := range c.args {
+ if sanitizing && (strings.Contains(arg, "://") && strings.Contains(arg, "@")) {
+ a = append(a, debugQuote(util.SanitizeCredentialURLs(arg)))
+ } else {
+ a = append(a, debugQuote(arg))
+ }
+ }
+ return strings.Join(a, " ")
+}
+
+// NewCommand creates and returns a new Git Command based on given command and arguments.
+// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
+func NewCommand(ctx context.Context, args ...internal.CmdArg) *Command {
+ // Make an explicit copy of globalCommandArgs, otherwise append might overwrite it
+ cargs := make([]string, 0, len(globalCommandArgs)+len(args))
+ for _, arg := range globalCommandArgs {
+ cargs = append(cargs, string(arg))
+ }
+ for _, arg := range args {
+ cargs = append(cargs, string(arg))
+ }
+ return &Command{
+ prog: GitExecutable,
+ args: cargs,
+ parentContext: ctx,
+ globalArgsLength: len(globalCommandArgs),
+ }
+}
+
+// NewCommandContextNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
+// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
+func NewCommandContextNoGlobals(ctx context.Context, args ...internal.CmdArg) *Command {
+ cargs := make([]string, 0, len(args))
+ for _, arg := range args {
+ cargs = append(cargs, string(arg))
+ }
+ return &Command{
+ prog: GitExecutable,
+ args: cargs,
+ parentContext: ctx,
+ }
+}
+
+// SetParentContext sets the parent context for this command
+func (c *Command) SetParentContext(ctx context.Context) *Command {
+ c.parentContext = ctx
+ return c
+}
+
+// SetDescription sets the description for this command which be returned on c.String()
+func (c *Command) SetDescription(desc string) *Command {
+ c.desc = desc
+ return c
+}
+
+// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option)
+func isSafeArgumentValue(s string) bool {
+ return s == "" || s[0] != '-'
+}
+
+// isValidArgumentOption checks if the argument is a valid option (starting with '-').
+// It doesn't check whether the option is supported or not
+func isValidArgumentOption(s string) bool {
+ return s != "" && s[0] == '-'
+}
+
+// AddArguments adds new git arguments (option/value) to the command. It only accepts string literals, or trusted CmdArg.
+// Type CmdArg is in the internal package, so it can not be used outside of this package directly,
+// it makes sure that user-provided arguments won't cause RCE risks.
+// User-provided arguments should be passed by other AddXxx functions
+func (c *Command) AddArguments(args ...internal.CmdArg) *Command {
+ for _, arg := range args {
+ c.args = append(c.args, string(arg))
+ }
+ return c
+}
+
+// AddOptionValues adds a new option with a list of non-option values
+// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}.
+// The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val).
+func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command {
+ if !isValidArgumentOption(string(opt)) {
+ c.brokenArgs = append(c.brokenArgs, string(opt))
+ return c
+ }
+ c.args = append(c.args, string(opt))
+ c.AddDynamicArguments(args...)
+ return c
+}
+
+// AddGitGrepExpression adds an expression option (-e) to git-grep command
+// It is different from AddOptionValues in that it allows the actual expression
+// to not be filtered out for leading dashes (which is otherwise a security feature
+// of AddOptionValues).
+func (c *Command) AddGitGrepExpression(exp string) *Command {
+ if c.args[len(globalCommandArgs)] != "grep" {
+ panic("function called on a non-grep git program: " + c.args[0])
+ }
+ c.args = append(c.args, "-e", exp)
+ return c
+}
+
+// AddOptionFormat adds a new option with a format string and arguments
+// For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}.
+func (c *Command) AddOptionFormat(opt string, args ...any) *Command {
+ if !isValidArgumentOption(opt) {
+ c.brokenArgs = append(c.brokenArgs, opt)
+ return c
+ }
+ // a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP
+ if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) {
+ c.brokenArgs = append(c.brokenArgs, opt)
+ return c
+ }
+ s := fmt.Sprintf(opt, args...)
+ c.args = append(c.args, s)
+ return c
+}
+
+// AddDynamicArguments adds new dynamic argument values to the command.
+// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options.
+// TODO: in the future, this function can be renamed to AddArgumentValues
+func (c *Command) AddDynamicArguments(args ...string) *Command {
+ for _, arg := range args {
+ if !isSafeArgumentValue(arg) {
+ c.brokenArgs = append(c.brokenArgs, arg)
+ }
+ }
+ if len(c.brokenArgs) != 0 {
+ return c
+ }
+ c.args = append(c.args, args...)
+ return c
+}
+
+// AddDashesAndList adds the "--" and then add the list as arguments, it's usually for adding file list
+// At the moment, this function can be only called once, maybe in future it can be refactored to support multiple calls (if necessary)
+func (c *Command) AddDashesAndList(list ...string) *Command {
+ c.args = append(c.args, "--")
+ // Some old code also checks `arg != ""`, IMO it's not necessary.
+ // If the check is needed, the list should be prepared before the call to this function
+ c.args = append(c.args, list...)
+ return c
+}
+
+// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
+// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead
+func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
+ ret := make(TrustedCmdArgs, len(args))
+ for i, arg := range args {
+ ret[i] = internal.CmdArg(arg)
+ }
+ return ret
+}
+
+// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
+type RunOpts struct {
+ Env []string
+ Timeout time.Duration
+ UseContextTimeout bool
+
+ // Dir is the working dir for the git command, however:
+ // FIXME: this could be incorrect in many cases, for example:
+ // * /some/path/.git
+ // * /some/path/.git/gitea-data/data/repositories/user/repo.git
+ // If "user/repo.git" is invalid/broken, then running git command in it will use "/some/path/.git", and produce unexpected results
+ // The correct approach is to use `--git-dir" global argument
+ Dir string
+
+ Stdout, Stderr io.Writer
+
+ // Stdin is used for passing input to the command
+ // The caller must make sure the Stdin writer is closed properly to finish the Run function.
+ // Otherwise, the Run function may hang for long time or forever, especially when the Git's context deadline is not the same as the caller's.
+ // Some common mistakes:
+ // * `defer stdinWriter.Close()` then call `cmd.Run()`: the Run() would never return if the command is killed by timeout
+ // * `go { case <- parentContext.Done(): stdinWriter.Close() }` with `cmd.Run(DefaultTimeout)`: the command would have been killed by timeout but the Run doesn't return until stdinWriter.Close()
+ // * `go { if stdoutReader.Read() err != nil: stdinWriter.Close() }` with `cmd.Run()`: the stdoutReader may never return error if the command is killed by timeout
+ // In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture.
+ Stdin io.Reader
+
+ PipelineFunc func(context.Context, context.CancelFunc) error
+}
+
+func commonBaseEnvs() []string {
+ // at the moment, do not set "GIT_CONFIG_NOSYSTEM", users may have put some configs like "receive.certNonceSeed" in it
+ envs := []string{
+ "HOME=" + HomeDir(), // make Gitea use internal git config only, to prevent conflicts with user's git config
+ "GIT_NO_REPLACE_OBJECTS=1", // ignore replace references (https://git-scm.com/docs/git-replace)
+ }
+
+ // some environment variables should be passed to git command
+ passThroughEnvKeys := []string{
+ "GNUPGHOME", // git may call gnupg to do commit signing
+ }
+ for _, key := range passThroughEnvKeys {
+ if val, ok := os.LookupEnv(key); ok {
+ envs = append(envs, key+"="+val)
+ }
+ }
+ return envs
+}
+
+// CommonGitCmdEnvs returns the common environment variables for a "git" command.
+func CommonGitCmdEnvs() []string {
+ return append(commonBaseEnvs(), []string{
+ "LC_ALL=" + DefaultLocale,
+ "GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3
+ }...)
+}
+
+// CommonCmdServEnvs is like CommonGitCmdEnvs, but it only returns minimal required environment variables for the "gitea serv" command
+func CommonCmdServEnvs() []string {
+ return commonBaseEnvs()
+}
+
+var ErrBrokenCommand = errors.New("git command is broken")
+
+// Run runs the command with the RunOpts
+func (c *Command) Run(opts *RunOpts) error {
+ if len(c.brokenArgs) != 0 {
+ log.Error("git command is broken: %s, broken args: %s", c.String(), strings.Join(c.brokenArgs, " "))
+ return ErrBrokenCommand
+ }
+ if opts == nil {
+ opts = &RunOpts{}
+ }
+
+ // We must not change the provided options
+ timeout := opts.Timeout
+ if timeout <= 0 {
+ timeout = defaultCommandExecutionTimeout
+ }
+
+ if len(opts.Dir) == 0 {
+ log.Debug("git.Command.Run: %s", c)
+ } else {
+ log.Debug("git.Command.RunDir(%s): %s", opts.Dir, c)
+ }
+
+ desc := c.desc
+ if desc == "" {
+ if opts.Dir == "" {
+ desc = fmt.Sprintf("git: %s", c.toString(true))
+ } else {
+ desc = fmt.Sprintf("git(dir:%s): %s", opts.Dir, c.toString(true))
+ }
+ }
+
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var finished context.CancelFunc
+
+ if opts.UseContextTimeout {
+ ctx, cancel, finished = process.GetManager().AddContext(c.parentContext, desc)
+ } else {
+ ctx, cancel, finished = process.GetManager().AddContextTimeout(c.parentContext, timeout, desc)
+ }
+ defer finished()
+
+ startTime := time.Now()
+
+ cmd := exec.CommandContext(ctx, c.prog, c.args...)
+ if opts.Env == nil {
+ cmd.Env = os.Environ()
+ } else {
+ cmd.Env = opts.Env
+ }
+
+ process.SetSysProcAttribute(cmd)
+ cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
+ cmd.Dir = opts.Dir
+ cmd.Stdout = opts.Stdout
+ cmd.Stderr = opts.Stderr
+ cmd.Stdin = opts.Stdin
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ if opts.PipelineFunc != nil {
+ err := opts.PipelineFunc(ctx, cancel)
+ if err != nil {
+ cancel()
+ _ = cmd.Wait()
+ return err
+ }
+ }
+
+ err := cmd.Wait()
+ elapsed := time.Since(startTime)
+ if elapsed > time.Second {
+ log.Debug("slow git.Command.Run: %s (%s)", c, elapsed)
+ }
+
+ // We need to check if the context is canceled by the program on Windows.
+ // This is because Windows does not have signal checking when terminating the process.
+ // It always returns exit code 1, unlike Linux, which has many exit codes for signals.
+ if runtime.GOOS == "windows" &&
+ err != nil &&
+ err.Error() == "" &&
+ cmd.ProcessState.ExitCode() == 1 &&
+ ctx.Err() == context.Canceled {
+ return ctx.Err()
+ }
+
+ if err != nil && ctx.Err() != context.DeadlineExceeded {
+ return err
+ }
+
+ return ctx.Err()
+}
+
+type RunStdError interface {
+ error
+ Unwrap() error
+ Stderr() string
+}
+
+type runStdError struct {
+ err error
+ stderr string
+ errMsg string
+}
+
+func (r *runStdError) Error() string {
+ // the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
+ if r.errMsg == "" {
+ r.errMsg = ConcatenateError(r.err, r.stderr).Error()
+ }
+ return r.errMsg
+}
+
+func (r *runStdError) Unwrap() error {
+ return r.err
+}
+
+func (r *runStdError) Stderr() string {
+ return r.stderr
+}
+
+func IsErrorExitCode(err error, code int) bool {
+ var exitError *exec.ExitError
+ if errors.As(err, &exitError) {
+ return exitError.ExitCode() == code
+ }
+ return false
+}
+
+// RunStdString runs the command with options and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
+func (c *Command) RunStdString(opts *RunOpts) (stdout, stderr string, runErr RunStdError) {
+ stdoutBytes, stderrBytes, err := c.RunStdBytes(opts)
+ stdout = util.UnsafeBytesToString(stdoutBytes)
+ stderr = util.UnsafeBytesToString(stderrBytes)
+ if err != nil {
+ return stdout, stderr, &runStdError{err: err, stderr: stderr}
+ }
+ // even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
+ return stdout, stderr, nil
+}
+
+// RunStdBytes runs the command with options and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
+func (c *Command) RunStdBytes(opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
+ if opts == nil {
+ opts = &RunOpts{}
+ }
+ if opts.Stdout != nil || opts.Stderr != nil {
+ // we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
+ panic("stdout and stderr field must be nil when using RunStdBytes")
+ }
+ stdoutBuf := &bytes.Buffer{}
+ stderrBuf := &bytes.Buffer{}
+
+ // We must not change the provided options as it could break future calls - therefore make a copy.
+ newOpts := &RunOpts{
+ Env: opts.Env,
+ Timeout: opts.Timeout,
+ UseContextTimeout: opts.UseContextTimeout,
+ Dir: opts.Dir,
+ Stdout: stdoutBuf,
+ Stderr: stderrBuf,
+ Stdin: opts.Stdin,
+ PipelineFunc: opts.PipelineFunc,
+ }
+
+ err := c.Run(newOpts)
+ stderr = stderrBuf.Bytes()
+ if err != nil {
+ return nil, stderr, &runStdError{err: err, stderr: util.UnsafeBytesToString(stderr)}
+ }
+ // even if there is no err, there could still be some stderr output
+ return stdoutBuf.Bytes(), stderr, nil
+}
+
+// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests
+func AllowLFSFiltersArgs() TrustedCmdArgs {
+ // Now here we should explicitly allow lfs filters to run
+ filteredLFSGlobalArgs := make(TrustedCmdArgs, len(globalCommandArgs))
+ j := 0
+ for _, arg := range globalCommandArgs {
+ if strings.Contains(string(arg), "lfs") {
+ j--
+ } else {
+ filteredLFSGlobalArgs[j] = arg
+ j++
+ }
+ }
+ return filteredLFSGlobalArgs[:j]
+}
diff --git a/modules/git/command_race_test.go b/modules/git/command_race_test.go
new file mode 100644
index 0000000..f567406
--- /dev/null
+++ b/modules/git/command_race_test.go
@@ -0,0 +1,38 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build race
+
+package git
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestRunWithContextNoTimeout(t *testing.T) {
+ maxLoops := 10
+
+ // 'git --version' does not block so it must be finished before the timeout triggered.
+ cmd := NewCommand(context.Background(), "--version")
+ for i := 0; i < maxLoops; i++ {
+ if err := cmd.Run(&RunOpts{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestRunWithContextTimeout(t *testing.T) {
+ maxLoops := 10
+
+ // 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
+ cmd := NewCommand(context.Background(), "hash-object", "--stdin")
+ for i := 0; i < maxLoops; i++ {
+ if err := cmd.Run(&RunOpts{Timeout: 1 * time.Millisecond}); err != nil {
+ if err != context.DeadlineExceeded {
+ t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
+ }
+ }
+ }
+}
diff --git a/modules/git/command_test.go b/modules/git/command_test.go
new file mode 100644
index 0000000..d3b8338
--- /dev/null
+++ b/modules/git/command_test.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRunWithContextStd(t *testing.T) {
+ cmd := NewCommand(context.Background(), "--version")
+ stdout, stderr, err := cmd.RunStdString(&RunOpts{})
+ require.NoError(t, err)
+ assert.Empty(t, stderr)
+ assert.Contains(t, stdout, "git version")
+
+ cmd = NewCommand(context.Background(), "--no-such-arg")
+ stdout, stderr, err = cmd.RunStdString(&RunOpts{})
+ if assert.Error(t, err) {
+ assert.Equal(t, stderr, err.Stderr())
+ assert.Contains(t, err.Stderr(), "unknown option:")
+ assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
+ assert.Empty(t, stdout)
+ }
+
+ cmd = NewCommand(context.Background())
+ cmd.AddDynamicArguments("-test")
+ require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand)
+
+ cmd = NewCommand(context.Background())
+ cmd.AddDynamicArguments("--test")
+ require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand)
+
+ subCmd := "version"
+ cmd = NewCommand(context.Background()).AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production
+ stdout, stderr, err = cmd.RunStdString(&RunOpts{})
+ require.NoError(t, err)
+ assert.Empty(t, stderr)
+ assert.Contains(t, stdout, "git version")
+}
+
+func TestGitArgument(t *testing.T) {
+ assert.True(t, isValidArgumentOption("-x"))
+ assert.True(t, isValidArgumentOption("--xx"))
+ assert.False(t, isValidArgumentOption(""))
+ assert.False(t, isValidArgumentOption("x"))
+
+ assert.True(t, isSafeArgumentValue(""))
+ assert.True(t, isSafeArgumentValue("x"))
+ assert.False(t, isSafeArgumentValue("-x"))
+}
+
+func TestCommandString(t *testing.T) {
+ cmd := NewCommandContextNoGlobals(context.Background(), "a", "-m msg", "it's a test", `say "hello"`)
+ assert.EqualValues(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.String())
+
+ cmd = NewCommandContextNoGlobals(context.Background(), "url: https://a:b@c/")
+ assert.EqualValues(t, cmd.prog+` "url: https://sanitized-credential@c/"`, cmd.toString(true))
+}
+
+func TestGrepOnlyFunction(t *testing.T) {
+ cmd := NewCommand(context.Background(), "anything-but-grep")
+ assert.Panics(t, func() {
+ cmd.AddGitGrepExpression("whatever")
+ })
+}
diff --git a/modules/git/commit.go b/modules/git/commit.go
new file mode 100644
index 0000000..b5ae2e0
--- /dev/null
+++ b/modules/git/commit.go
@@ -0,0 +1,590 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Commit represents a git commit.
+type Commit struct {
+ Tree
+ ID ObjectID // The ID of this commit object
+ Author *Signature
+ Committer *Signature
+ CommitMessage string
+ Signature *ObjectSignature
+
+ Parents []ObjectID // ID strings
+ submoduleCache *ObjectCache
+}
+
+// Message returns the commit message. Same as retrieving CommitMessage directly.
+func (c *Commit) Message() string {
+ return c.CommitMessage
+}
+
+// Summary returns first line of commit message.
+// The string is forced to be valid UTF8
+func (c *Commit) Summary() string {
+ return strings.ToValidUTF8(strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0], "?")
+}
+
+// ParentID returns oid of n-th parent (0-based index).
+// It returns nil if no such parent exists.
+func (c *Commit) ParentID(n int) (ObjectID, error) {
+ if n >= len(c.Parents) {
+ return nil, ErrNotExist{"", ""}
+ }
+ return c.Parents[n], nil
+}
+
+// Parent returns n-th parent (0-based index) of the commit.
+func (c *Commit) Parent(n int) (*Commit, error) {
+ id, err := c.ParentID(n)
+ if err != nil {
+ return nil, err
+ }
+ parent, err := c.repo.getCommit(id)
+ if err != nil {
+ return nil, err
+ }
+ return parent, nil
+}
+
+// ParentCount returns number of parents of the commit.
+// 0 if this is the root commit, otherwise 1,2, etc.
+func (c *Commit) ParentCount() int {
+ return len(c.Parents)
+}
+
+// GetCommitByPath return the commit of relative path object.
+func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
+ if c.repo.LastCommitCache != nil {
+ return c.repo.LastCommitCache.GetCommitByPath(c.ID.String(), relpath)
+ }
+ return c.repo.getCommitByPathWithID(c.ID, relpath)
+}
+
+// AddChanges marks local changes to be ready for commit.
+func AddChanges(repoPath string, all bool, files ...string) error {
+ return AddChangesWithArgs(repoPath, globalCommandArgs, all, files...)
+}
+
+// AddChangesWithArgs marks local changes to be ready for commit.
+func AddChangesWithArgs(repoPath string, globalArgs TrustedCmdArgs, all bool, files ...string) error {
+ cmd := NewCommandContextNoGlobals(DefaultContext, globalArgs...).AddArguments("add")
+ if all {
+ cmd.AddArguments("--all")
+ }
+ cmd.AddDashesAndList(files...)
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ return err
+}
+
+// CommitChangesOptions the options when a commit created
+type CommitChangesOptions struct {
+ Committer *Signature
+ Author *Signature
+ Message string
+}
+
+// CommitChanges commits local changes with given committer, author and message.
+// If author is nil, it will be the same as committer.
+func CommitChanges(repoPath string, opts CommitChangesOptions) error {
+ cargs := make(TrustedCmdArgs, len(globalCommandArgs))
+ copy(cargs, globalCommandArgs)
+ return CommitChangesWithArgs(repoPath, cargs, opts)
+}
+
+// CommitChangesWithArgs commits local changes with given committer, author and message.
+// If author is nil, it will be the same as committer.
+func CommitChangesWithArgs(repoPath string, args TrustedCmdArgs, opts CommitChangesOptions) error {
+ cmd := NewCommandContextNoGlobals(DefaultContext, args...)
+ if opts.Committer != nil {
+ cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name)
+ cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email)
+ }
+ cmd.AddArguments("commit")
+
+ if opts.Author == nil {
+ opts.Author = opts.Committer
+ }
+ if opts.Author != nil {
+ cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)
+ }
+ cmd.AddOptionFormat("--message=%s", opts.Message)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ // No stderr but exit status 1 means nothing to commit.
+ if err != nil && err.Error() == "exit status 1" {
+ return nil
+ }
+ return err
+}
+
+// AllCommitsCount returns count of all commits in repository
+func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) {
+ cmd := NewCommand(ctx, "rev-list")
+ if hidePRRefs {
+ cmd.AddArguments("--exclude=" + PullPrefix + "*")
+ }
+ cmd.AddArguments("--all", "--count")
+ if len(files) > 0 {
+ cmd.AddDashesAndList(files...)
+ }
+
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return 0, err
+ }
+
+ return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+}
+
+// CommitsCountOptions the options when counting commits
+type CommitsCountOptions struct {
+ RepoPath string
+ Not string
+ Revision []string
+ RelPath []string
+}
+
+// CommitsCount returns number of total commits of until given revision.
+func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) {
+ cmd := NewCommand(ctx, "rev-list", "--count")
+
+ cmd.AddDynamicArguments(opts.Revision...)
+
+ if opts.Not != "" {
+ cmd.AddOptionValues("--not", opts.Not)
+ }
+
+ if len(opts.RelPath) > 0 {
+ cmd.AddDashesAndList(opts.RelPath...)
+ }
+
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: opts.RepoPath})
+ if err != nil {
+ return 0, err
+ }
+
+ return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+}
+
+// CommitsCount returns number of total commits of until current revision.
+func (c *Commit) CommitsCount() (int64, error) {
+ return CommitsCount(c.repo.Ctx, CommitsCountOptions{
+ RepoPath: c.repo.Path,
+ Revision: []string{c.ID.String()},
+ })
+}
+
+// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
+func (c *Commit) CommitsByRange(page, pageSize int, not string) ([]*Commit, error) {
+ return c.repo.commitsByRange(c.ID, page, pageSize, not)
+}
+
+// CommitsBefore returns all the commits before current revision
+func (c *Commit) CommitsBefore() ([]*Commit, error) {
+ return c.repo.getCommitsBefore(c.ID)
+}
+
+// HasPreviousCommit returns true if a given commitHash is contained in commit's parents
+func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) {
+ this := c.ID.String()
+ that := objectID.String()
+
+ if this == that {
+ return false, nil
+ }
+
+ _, _, err := NewCommand(c.repo.Ctx, "merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(&RunOpts{Dir: c.repo.Path})
+ if err == nil {
+ return true, nil
+ }
+ var exitError *exec.ExitError
+ if errors.As(err, &exitError) {
+ if exitError.ProcessState.ExitCode() == 1 && len(exitError.Stderr) == 0 {
+ return false, nil
+ }
+ }
+ return false, err
+}
+
+// IsForcePush returns true if a push from oldCommitHash to this is a force push
+func (c *Commit) IsForcePush(oldCommitID string) (bool, error) {
+ objectFormat, err := c.repo.GetObjectFormat()
+ if err != nil {
+ return false, err
+ }
+ if oldCommitID == objectFormat.EmptyObjectID().String() {
+ return false, nil
+ }
+
+ oldCommit, err := c.repo.GetCommit(oldCommitID)
+ if err != nil {
+ return false, err
+ }
+ hasPreviousCommit, err := c.HasPreviousCommit(oldCommit.ID)
+ return !hasPreviousCommit, err
+}
+
+// CommitsBeforeLimit returns num commits before current revision
+func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) {
+ return c.repo.getCommitsBeforeLimit(c.ID, num)
+}
+
+// CommitsBeforeUntil returns the commits between commitID to current revision
+func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) {
+ endCommit, err := c.repo.GetCommit(commitID)
+ if err != nil {
+ return nil, err
+ }
+ return c.repo.CommitsBetween(c, endCommit)
+}
+
+// SearchCommitsOptions specify the parameters for SearchCommits
+type SearchCommitsOptions struct {
+ Keywords []string
+ Authors, Committers []string
+ After, Before string
+ All bool
+}
+
+// NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string
+func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions {
+ var keywords, authors, committers []string
+ var after, before string
+
+ fields := strings.Fields(searchString)
+ for _, k := range fields {
+ switch {
+ case strings.HasPrefix(k, "author:"):
+ authors = append(authors, strings.TrimPrefix(k, "author:"))
+ case strings.HasPrefix(k, "committer:"):
+ committers = append(committers, strings.TrimPrefix(k, "committer:"))
+ case strings.HasPrefix(k, "after:"):
+ after = strings.TrimPrefix(k, "after:")
+ case strings.HasPrefix(k, "before:"):
+ before = strings.TrimPrefix(k, "before:")
+ default:
+ keywords = append(keywords, k)
+ }
+ }
+
+ return SearchCommitsOptions{
+ Keywords: keywords,
+ Authors: authors,
+ Committers: committers,
+ After: after,
+ Before: before,
+ All: forAllRefs,
+ }
+}
+
+// SearchCommits returns the commits match the keyword before current revision
+func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) {
+ return c.repo.searchCommits(c.ID, opts)
+}
+
+// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
+func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) {
+ return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String())
+}
+
+// FileChangedSinceCommit Returns true if the file given has changed since the past commit
+// YOU MUST ENSURE THAT pastCommit is a valid commit ID.
+func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) {
+ return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String())
+}
+
+// HasFile returns true if the file given exists on this commit
+// This does only mean it's there - it does not mean the file was changed during the commit.
+func (c *Commit) HasFile(filename string) (bool, error) {
+ _, err := c.GetBlobByPath(filename)
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// GetFileContent reads a file content as a string or returns false if this was not possible
+func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
+ entry, err := c.GetTreeEntryByPath(filename)
+ if err != nil {
+ return "", err
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer r.Close()
+
+ if limit > 0 {
+ bs := make([]byte, limit)
+ n, err := util.ReadAtMost(r, bs)
+ if err != nil {
+ return "", err
+ }
+ return string(bs[:n]), nil
+ }
+
+ bytes, err := io.ReadAll(r)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes), nil
+}
+
+// GetSubModules get all the sub modules of current revision git tree
+func (c *Commit) GetSubModules() (*ObjectCache, error) {
+ if c.submoduleCache != nil {
+ return c.submoduleCache, nil
+ }
+
+ entry, err := c.GetTreeEntryByPath(".gitmodules")
+ if err != nil {
+ if _, ok := err.(ErrNotExist); ok {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ rd, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil, err
+ }
+
+ defer rd.Close()
+ scanner := bufio.NewScanner(rd)
+ c.submoduleCache = newObjectCache()
+ var ismodule bool
+ var path string
+ for scanner.Scan() {
+ if strings.HasPrefix(scanner.Text(), "[submodule") {
+ ismodule = true
+ continue
+ }
+ if ismodule {
+ fields := strings.Split(scanner.Text(), "=")
+ k := strings.TrimSpace(fields[0])
+ if k == "path" {
+ path = strings.TrimSpace(fields[1])
+ } else if k == "url" {
+ c.submoduleCache.Set(path, &SubModule{path, strings.TrimSpace(fields[1])})
+ ismodule = false
+ }
+ }
+ }
+ if err = scanner.Err(); err != nil {
+ return nil, fmt.Errorf("GetSubModules scan: %w", err)
+ }
+
+ return c.submoduleCache, nil
+}
+
+// GetSubModule get the sub module according entryname
+func (c *Commit) GetSubModule(entryname string) (*SubModule, error) {
+ modules, err := c.GetSubModules()
+ if err != nil {
+ return nil, err
+ }
+
+ if modules != nil {
+ module, has := modules.Get(entryname)
+ if has {
+ return module.(*SubModule), nil
+ }
+ }
+ return nil, nil
+}
+
+// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
+func (c *Commit) GetBranchName() (string, error) {
+ cmd := NewCommand(c.repo.Ctx, "name-rev")
+ if CheckGitVersionAtLeast("2.13.0") == nil {
+ cmd.AddArguments("--exclude", "refs/tags/*")
+ }
+ cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String())
+ data, _, err := cmd.RunStdString(&RunOpts{Dir: c.repo.Path})
+ if err != nil {
+ // handle special case where git can not describe commit
+ if strings.Contains(err.Error(), "cannot describe") {
+ return "", nil
+ }
+
+ return "", err
+ }
+
+ // name-rev commitID output will be "master" or "master~12"
+ return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil
+}
+
+// CommitFileStatus represents status of files in a commit.
+type CommitFileStatus struct {
+ Added []string
+ Removed []string
+ Modified []string
+}
+
+// NewCommitFileStatus creates a CommitFileStatus
+func NewCommitFileStatus() *CommitFileStatus {
+ return &CommitFileStatus{
+ []string{}, []string{}, []string{},
+ }
+}
+
+func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) {
+ rd := bufio.NewReader(stdout)
+ peek, err := rd.Peek(1)
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ if peek[0] == '\n' || peek[0] == '\x00' {
+ _, _ = rd.Discard(1)
+ }
+ for {
+ modifier, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ file, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ file = file[:len(file)-1]
+ switch modifier[0] {
+ case 'A':
+ fileStatus.Added = append(fileStatus.Added, file)
+ case 'D':
+ fileStatus.Removed = append(fileStatus.Removed, file)
+ case 'M':
+ fileStatus.Modified = append(fileStatus.Modified, file)
+ }
+ }
+}
+
+// GetCommitFileStatus returns file status of commit in given repository.
+func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) {
+ stdout, w := io.Pipe()
+ done := make(chan struct{})
+ fileStatus := NewCommitFileStatus()
+ go func() {
+ parseCommitFileStatus(fileStatus, stdout)
+ close(done)
+ }()
+
+ stderr := new(bytes.Buffer)
+ err := NewCommand(ctx, "log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ w.Close() // Close writer to exit parsing goroutine
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+
+ <-done
+ return fileStatus, nil
+}
+
+func parseCommitRenames(renames *[][2]string, stdout io.Reader) {
+ rd := bufio.NewReader(stdout)
+ for {
+ // Skip (R || three digits || NULL byte)
+ _, err := rd.Discard(5)
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ oldFileName, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ newFileName, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ oldFileName = strings.TrimSuffix(oldFileName, "\x00")
+ newFileName = strings.TrimSuffix(newFileName, "\x00")
+ *renames = append(*renames, [2]string{oldFileName, newFileName})
+ }
+}
+
+// GetCommitFileRenames returns the renames that the commit contains.
+func GetCommitFileRenames(ctx context.Context, repoPath, commitID string) ([][2]string, error) {
+ renames := [][2]string{}
+ stdout, w := io.Pipe()
+ done := make(chan struct{})
+ go func() {
+ parseCommitRenames(&renames, stdout)
+ close(done)
+ }()
+
+ stderr := new(bytes.Buffer)
+ err := NewCommand(ctx, "show", "--name-status", "--pretty=format:", "-z", "--diff-filter=R").AddDynamicArguments(commitID).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ w.Close() // Close writer to exit parsing goroutine
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+
+ <-done
+ return renames, nil
+}
+
+// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
+func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
+ commitID, _, err := NewCommand(ctx, "rev-parse").AddDynamicArguments(shortID).RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ if strings.Contains(err.Error(), "exit status 128") {
+ return "", ErrNotExist{shortID, ""}
+ }
+ return "", err
+ }
+ return strings.TrimSpace(commitID), nil
+}
+
+// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit
+func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
+ if c.repo == nil {
+ return nil, nil
+ }
+ return c.repo.GetDefaultPublicGPGKey(forceUpdate)
+}
diff --git a/modules/git/commit_info.go b/modules/git/commit_info.go
new file mode 100644
index 0000000..3b34b79
--- /dev/null
+++ b/modules/git/commit_info.go
@@ -0,0 +1,178 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "path"
+ "sort"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// CommitInfo describes the first commit with the provided entry
+type CommitInfo struct {
+ Entry *TreeEntry
+ Commit *Commit
+ SubModuleFile *SubModuleFile
+}
+
+// GetCommitsInfo gets information of all commits that are corresponding to these entries
+func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
+ entryPaths := make([]string, len(tes)+1)
+ // Get the commit for the treePath itself
+ entryPaths[0] = ""
+ for i, entry := range tes {
+ entryPaths[i+1] = entry.Name()
+ }
+
+ var err error
+
+ var revs map[string]*Commit
+ if commit.repo.LastCommitCache != nil {
+ var unHitPaths []string
+ revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, commit.repo.LastCommitCache)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(unHitPaths) > 0 {
+ sort.Strings(unHitPaths)
+ commits, err := GetLastCommitForPaths(ctx, commit, treePath, unHitPaths)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for pth, found := range commits {
+ revs[pth] = found
+ }
+ }
+ } else {
+ sort.Strings(entryPaths)
+ revs, err = GetLastCommitForPaths(ctx, commit, treePath, entryPaths)
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+
+ commitsInfo := make([]CommitInfo, len(tes))
+ for i, entry := range tes {
+ commitsInfo[i] = CommitInfo{
+ Entry: entry,
+ }
+
+ // Check if we have found a commit for this entry in time
+ if entryCommit, ok := revs[entry.Name()]; ok {
+ commitsInfo[i].Commit = entryCommit
+ } else {
+ log.Debug("missing commit for %s", entry.Name())
+ }
+
+ // If the entry if a submodule add a submodule file for this
+ if entry.IsSubModule() {
+ subModuleURL := ""
+ var fullPath string
+ if len(treePath) > 0 {
+ fullPath = treePath + "/" + entry.Name()
+ } else {
+ fullPath = entry.Name()
+ }
+ if subModule, err := commit.GetSubModule(fullPath); err != nil {
+ return nil, nil, err
+ } else if subModule != nil {
+ subModuleURL = subModule.URL
+ }
+ subModuleFile := NewSubModuleFile(commitsInfo[i].Commit, subModuleURL, entry.ID.String())
+ commitsInfo[i].SubModuleFile = subModuleFile
+ }
+ }
+
+ // Retrieve the commit for the treePath itself (see above). We basically
+ // get it for free during the tree traversal and it's used for listing
+ // pages to display information about newest commit for a given path.
+ var treeCommit *Commit
+ var ok bool
+ if treePath == "" {
+ treeCommit = commit
+ } else if treeCommit, ok = revs[""]; ok {
+ treeCommit.repo = commit.repo
+ }
+ return commitsInfo, treeCommit, nil
+}
+
+func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) {
+ var unHitEntryPaths []string
+ results := make(map[string]*Commit)
+ for _, p := range paths {
+ lastCommit, err := cache.Get(commitID, path.Join(treePath, p))
+ if err != nil {
+ return nil, nil, err
+ }
+ if lastCommit != nil {
+ results[p] = lastCommit
+ continue
+ }
+
+ unHitEntryPaths = append(unHitEntryPaths, p)
+ }
+
+ return results, unHitEntryPaths, nil
+}
+
+// GetLastCommitForPaths returns last commit information
+func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
+ // We read backwards from the commit to obtain all of the commits
+ revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
+ if err != nil {
+ return nil, err
+ }
+
+ batchStdinWriter, batchReader, cancel, err := commit.repo.CatFileBatch(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ commitsMap := map[string]*Commit{}
+ commitsMap[commit.ID.String()] = commit
+
+ commitCommits := map[string]*Commit{}
+ for path, commitID := range revs {
+ c, ok := commitsMap[commitID]
+ if ok {
+ commitCommits[path] = c
+ continue
+ }
+
+ if len(commitID) == 0 {
+ continue
+ }
+
+ _, err := batchStdinWriter.Write([]byte(commitID + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ _, typ, size, err := ReadBatchLine(batchReader)
+ if err != nil {
+ return nil, err
+ }
+ if typ != "commit" {
+ if err := DiscardFull(batchReader, size+1); err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("unexpected type: %s for commit id: %s", typ, commitID)
+ }
+ c, err = CommitFromReader(commit.repo, MustIDFromString(commitID), io.LimitReader(batchReader, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+ commitCommits[path] = c
+ }
+
+ return commitCommits, nil
+}
diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go
new file mode 100644
index 0000000..dbe9ab5
--- /dev/null
+++ b/modules/git/commit_info_test.go
@@ -0,0 +1,175 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testReposDir = "tests/repos/"
+)
+
+func cloneRepo(tb testing.TB, url string) (string, error) {
+ repoDir := tb.TempDir()
+ if err := Clone(DefaultContext, url, repoDir, CloneRepoOptions{
+ Mirror: false,
+ Bare: false,
+ Quiet: true,
+ Timeout: 5 * time.Minute,
+ }); err != nil {
+ return "", err
+ }
+ return repoDir, nil
+}
+
+func testGetCommitsInfo(t *testing.T, repo1 *Repository) {
+ // these test case are specific to the repo1 test repo
+ testCases := []struct {
+ CommitID string
+ Path string
+ ExpectedIDs map[string]string
+ ExpectedTreeCommit string
+ }{
+ {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{
+ "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
+ "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ }, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"},
+ {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{
+ "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3",
+ "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185",
+ }, "2839944139e0de9737a044f78b0e4b40d989a9e3"},
+ {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{
+ "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658",
+ }, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"},
+ {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{
+ "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
+ "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ "foo": "37991dec2c8e592043f47155ce4808d4580f9123",
+ }, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"},
+ }
+ for _, testCase := range testCases {
+ commit, err := repo1.GetCommit(testCase.CommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to get commit: %s from testcase due to error: %v", testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+ assert.NotNil(t, commit)
+ assert.NotNil(t, commit.Tree)
+ assert.NotNil(t, commit.Tree.repo)
+
+ tree, err := commit.Tree.SubTree(testCase.Path)
+ if err != nil {
+ require.NoError(t, err, "Unable to get subtree: %s of commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+
+ assert.NotNil(t, tree, "tree is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
+ assert.NotNil(t, tree.repo, "repo is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ require.NoError(t, err, "Unable to get entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+
+ // FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain.
+ commitsInfo, treeCommit, err := entries.GetCommitsInfo(context.TODO(), commit, testCase.Path)
+ require.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ if err != nil {
+ t.FailNow()
+ }
+ assert.Equal(t, testCase.ExpectedTreeCommit, treeCommit.ID.String())
+ assert.Len(t, commitsInfo, len(testCase.ExpectedIDs))
+ for _, commitInfo := range commitsInfo {
+ entry := commitInfo.Entry
+ commit := commitInfo.Commit
+ expectedID, ok := testCase.ExpectedIDs[entry.Name()]
+ if !assert.True(t, ok) {
+ continue
+ }
+ assert.Equal(t, expectedID, commit.ID.String())
+ }
+ }
+}
+
+func TestEntries_GetCommitsInfo(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ testGetCommitsInfo(t, bareRepo1)
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ clonedRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ defer clonedRepo1.Close()
+
+ testGetCommitsInfo(t, clonedRepo1)
+}
+
+func BenchmarkEntries_GetCommitsInfo(b *testing.B) {
+ type benchmarkType struct {
+ url string
+ name string
+ }
+
+ benchmarks := []benchmarkType{
+ {url: "https://github.com/go-gitea/gitea.git", name: "gitea"},
+ {url: "https://github.com/ethantkoenig/manyfiles.git", name: "manyfiles"},
+ {url: "https://github.com/moby/moby.git", name: "moby"},
+ {url: "https://github.com/golang/go.git", name: "go"},
+ {url: "https://github.com/torvalds/linux.git", name: "linux"},
+ }
+
+ doBenchmark := func(benchmark benchmarkType) {
+ var commit *Commit
+ var entries Entries
+ var repo *Repository
+ repoPath, err := cloneRepo(b, benchmark.url)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ if repo, err = openRepositoryWithDefaultContext(repoPath); err != nil {
+ b.Fatal(err)
+ }
+ defer repo.Close()
+
+ if commit, err = repo.GetBranchCommit("master"); err != nil {
+ b.Fatal(err)
+ } else if entries, err = commit.Tree.ListEntries(); err != nil {
+ b.Fatal(err)
+ }
+ entries.Sort()
+ b.ResetTimer()
+ b.Run(benchmark.name, func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, err := entries.GetCommitsInfo(context.Background(), commit, "")
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+
+ for _, benchmark := range benchmarks {
+ doBenchmark(benchmark)
+ }
+}
diff --git a/modules/git/commit_reader.go b/modules/git/commit_reader.go
new file mode 100644
index 0000000..8e2523d
--- /dev/null
+++ b/modules/git/commit_reader.go
@@ -0,0 +1,110 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "io"
+ "strings"
+)
+
+// CommitFromReader will generate a Commit from a provided reader
+// We need this to interpret commits from cat-file or cat-file --batch
+//
+// If used as part of a cat-file --batch stream you need to limit the reader to the correct size
+func CommitFromReader(gitRepo *Repository, objectID ObjectID, reader io.Reader) (*Commit, error) {
+ commit := &Commit{
+ ID: objectID,
+ Author: &Signature{},
+ Committer: &Signature{},
+ }
+
+ payloadSB := new(strings.Builder)
+ signatureSB := new(strings.Builder)
+ messageSB := new(strings.Builder)
+ message := false
+ pgpsig := false
+
+ bufReader, ok := reader.(*bufio.Reader)
+ if !ok {
+ bufReader = bufio.NewReader(reader)
+ }
+
+readLoop:
+ for {
+ line, err := bufReader.ReadBytes('\n')
+ if err != nil {
+ if err == io.EOF {
+ if message {
+ _, _ = messageSB.Write(line)
+ }
+ _, _ = payloadSB.Write(line)
+ break readLoop
+ }
+ return nil, err
+ }
+ if pgpsig {
+ if len(line) > 0 && line[0] == ' ' {
+ _, _ = signatureSB.Write(line[1:])
+ continue
+ }
+ pgpsig = false
+ }
+
+ if !message {
+ // This is probably not correct but is copied from go-gits interpretation...
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 {
+ message = true
+ _, _ = payloadSB.Write(line)
+ continue
+ }
+
+ split := bytes.SplitN(trimmed, []byte{' '}, 2)
+ var data []byte
+ if len(split) > 1 {
+ data = split[1]
+ }
+
+ switch string(split[0]) {
+ case "tree":
+ commit.Tree = *NewTree(gitRepo, MustIDFromString(string(data)))
+ _, _ = payloadSB.Write(line)
+ case "parent":
+ commit.Parents = append(commit.Parents, MustIDFromString(string(data)))
+ _, _ = payloadSB.Write(line)
+ case "author":
+ commit.Author = &Signature{}
+ commit.Author.Decode(data)
+ _, _ = payloadSB.Write(line)
+ case "committer":
+ commit.Committer = &Signature{}
+ commit.Committer.Decode(data)
+ _, _ = payloadSB.Write(line)
+ case "encoding":
+ _, _ = payloadSB.Write(line)
+ case "gpgsig":
+ fallthrough
+ case "gpgsig-sha256": // FIXME: no intertop, so only 1 exists at present.
+ _, _ = signatureSB.Write(data)
+ _ = signatureSB.WriteByte('\n')
+ pgpsig = true
+ }
+ } else {
+ _, _ = messageSB.Write(line)
+ _, _ = payloadSB.Write(line)
+ }
+ }
+ commit.CommitMessage = messageSB.String()
+ commit.Signature = &ObjectSignature{
+ Signature: signatureSB.String(),
+ Payload: payloadSB.String(),
+ }
+ if len(commit.Signature.Signature) == 0 {
+ commit.Signature = nil
+ }
+
+ return commit, nil
+}
diff --git a/modules/git/commit_sha256_test.go b/modules/git/commit_sha256_test.go
new file mode 100644
index 0000000..9e56829
--- /dev/null
+++ b/modules/git/commit_sha256_test.go
@@ -0,0 +1,211 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCommitsCountSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), commitsCount)
+}
+
+func TestCommitsCountWithoutBaseSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Not: "main",
+ Revision: []string{"branch1"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), commitsCount)
+}
+
+func TestGetFullCommitIDSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "f004f4")
+ require.NoError(t, err)
+ assert.Equal(t, "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc", id)
+}
+
+func TestGetFullCommitIDErrorSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown")
+ assert.Empty(t, id)
+ if assert.Error(t, err) {
+ assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
+ }
+}
+
+func TestCommitFromReaderSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ commitString := `9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 commit 1114
+tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
+parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
+author Adam Majer <amajer@suse.de> 1698676906 +0100
+committer Adam Majer <amajer@suse.de> 1698676906 +0100
+gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
+ dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
+ aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
+ WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
+ 1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
+ JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
+ oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
+ U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
+ zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
+ VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
+ HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
+ 8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
+ =xybZ
+ -----END PGP SIGNATURE-----
+
+signed commit`
+
+ sha := &Sha256Hash{
+ 0x94, 0x33, 0xb2, 0xa6, 0x2b, 0x96, 0x4c, 0x17, 0xa4, 0x48, 0x5a, 0xe1, 0x80, 0xf4, 0x5f, 0x59,
+ 0x5d, 0x3e, 0x69, 0xd3, 0x1b, 0x78, 0x60, 0x87, 0x77, 0x5e, 0x28, 0xc6, 0xb6, 0x39, 0x9d, 0xf0,
+ }
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare_sha256"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ if !assert.NotNil(t, commitFromReader) {
+ return
+ }
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
+dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
+aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
+WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
+1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
+JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
+oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
+U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
+zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
+VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
+HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
+8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
+=xybZ
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
+parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
+author Adam Majer <amajer@suse.de> 1698676906 +0100
+committer Adam Majer <amajer@suse.de> 1698676906 +0100
+
+signed commit`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "Adam Majer <amajer@suse.de>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestHasPreviousCommitSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc")
+ require.NoError(t, err)
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+
+ parentSHA := MustIDFromString("b0ec7af4547047f12d5093e37ef8f1b3b5415ed8ee17894d43a34d7d34212e9c")
+ notParentSHA := MustIDFromString("42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236")
+ assert.Equal(t, parentSHA.Type(), objectFormat)
+ assert.Equal(t, "sha256", objectFormat.Name())
+
+ haz, err := commit.HasPreviousCommit(parentSHA)
+ require.NoError(t, err)
+ assert.True(t, haz)
+
+ hazNot, err := commit.HasPreviousCommit(notParentSHA)
+ require.NoError(t, err)
+ assert.False(t, hazNot)
+
+ selfNot, err := commit.HasPreviousCommit(commit.ID)
+ require.NoError(t, err)
+ assert.False(t, selfNot)
+}
+
+func TestGetCommitFileStatusMergesSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256")
+
+ commitFileStatus, err := GetCommitFileStatus(DefaultContext, bareRepo1Path, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1")
+ require.NoError(t, err)
+
+ expected := CommitFileStatus{
+ []string{
+ "add_file.txt",
+ },
+ []string{},
+ []string{
+ "to_modify.txt",
+ },
+ }
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+
+ expected = CommitFileStatus{
+ []string{},
+ []string{
+ "to_remove.txt",
+ },
+ []string{},
+ }
+
+ commitFileStatus, err = GetCommitFileStatus(DefaultContext, bareRepo1Path, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172")
+ require.NoError(t, err)
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+}
diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go
new file mode 100644
index 0000000..af85bfe
--- /dev/null
+++ b/modules/git/commit_test.go
@@ -0,0 +1,371 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCommitsCount(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), commitsCount)
+}
+
+func TestCommitsCountWithoutBase(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Not: "master",
+ Revision: []string{"branch1"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), commitsCount)
+}
+
+func TestGetFullCommitID(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "8006ff9a")
+ require.NoError(t, err)
+ assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id)
+}
+
+func TestGetFullCommitIDError(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown")
+ assert.Empty(t, id)
+ if assert.Error(t, err) {
+ assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
+ }
+}
+
+func TestCommitFromReader(t *testing.T) {
+ commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074
+tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
+parent 37991dec2c8e592043f47155ce4808d4580f9123
+author silverwind <me@silverwind.io> 1563741793 +0200
+committer silverwind <me@silverwind.io> 1563741793 +0200
+gpgsig -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
+ lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
+ xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
+ vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
+ R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
+ FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
+ /maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
+ S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
+ sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
+ 1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
+ mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
+ 1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
+ =FRsO
+ -----END PGP SIGNATURE-----
+
+empty commit`
+
+ sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ require.NotNil(t, commitFromReader)
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
+lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
+xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
+vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
+R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
+FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
+/maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
+S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
+sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
+1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
+mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
+1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
+=FRsO
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
+parent 37991dec2c8e592043f47155ce4808d4580f9123
+author silverwind <me@silverwind.io> 1563741793 +0200
+committer silverwind <me@silverwind.io> 1563741793 +0200
+
+empty commit`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "silverwind <me@silverwind.io>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestCommitWithEncodingFromReader(t *testing.T) {
+ commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074
+tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
+parent 47b24e7ab977ed31c5a39989d570847d6d0052af
+author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+encoding ISO-8859-1
+gpgsig -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
+ Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
+ gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
+ zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
+ frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
+ FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
+ G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
+ SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
+ yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
+ jw4YcO5u
+ =r3UU
+ -----END PGP SIGNATURE-----
+
+ISO-8859-1`
+
+ sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ require.NotNil(t, commitFromReader)
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
+Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
+gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
+zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
+frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
+FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
+G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
+SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
+yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
+jw4YcO5u
+=r3UU
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
+parent 47b24e7ab977ed31c5a39989d570847d6d0052af
+author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+encoding ISO-8859-1
+
+ISO-8859-1`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "KN4CK3R <admin@oldschoolhack.me>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestHasPreviousCommit(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0")
+ require.NoError(t, err)
+
+ parentSHA := MustIDFromString("8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2")
+ notParentSHA := MustIDFromString("2839944139e0de9737a044f78b0e4b40d989a9e3")
+
+ haz, err := commit.HasPreviousCommit(parentSHA)
+ require.NoError(t, err)
+ assert.True(t, haz)
+
+ hazNot, err := commit.HasPreviousCommit(notParentSHA)
+ require.NoError(t, err)
+ assert.False(t, hazNot)
+
+ selfNot, err := commit.HasPreviousCommit(commit.ID)
+ require.NoError(t, err)
+ assert.False(t, selfNot)
+}
+
+func TestParseCommitFileStatus(t *testing.T) {
+ type testcase struct {
+ output string
+ added []string
+ removed []string
+ modified []string
+ }
+
+ kases := []testcase{
+ {
+ // Merge commit
+ output: "MM\x00options/locale/locale_en-US.ini\x00",
+ modified: []string{
+ "options/locale/locale_en-US.ini",
+ },
+ added: []string{},
+ removed: []string{},
+ },
+ {
+ // Spaces commit
+ output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
+ removed: []string{
+ "b",
+ "b b/b",
+ },
+ modified: []string{},
+ added: []string{
+ "b b/b b/b b/b",
+ "b b/b b/b b/b b/b",
+ },
+ },
+ {
+ // larger commit
+ output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00",
+ modified: []string{
+ "go.mod",
+ "go.sum",
+ "modules/ssh/ssh.go",
+ "vendor/github.com/gliderlabs/ssh/circle.yml",
+ "vendor/github.com/gliderlabs/ssh/context.go",
+ "vendor/github.com/gliderlabs/ssh/server.go",
+ "vendor/github.com/gliderlabs/ssh/session.go",
+ "vendor/github.com/gliderlabs/ssh/ssh.go",
+ "vendor/golang.org/x/sys/unix/mkerrors.sh",
+ "vendor/golang.org/x/sys/unix/syscall_darwin.go",
+ "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_linux.go",
+ "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go",
+ "vendor/modules.txt",
+ },
+ added: []string{
+ "vendor/github.com/gliderlabs/ssh/go.mod",
+ "vendor/github.com/gliderlabs/ssh/go.sum",
+ },
+ removed: []string{},
+ },
+ {
+ // git 1.7.2 adds an unnecessary \x00 on merge commit
+ output: "\x00MM\x00options/locale/locale_en-US.ini\x00",
+ modified: []string{
+ "options/locale/locale_en-US.ini",
+ },
+ added: []string{},
+ removed: []string{},
+ },
+ {
+ // git 1.7.2 adds an unnecessary \n on normal commit
+ output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
+ removed: []string{
+ "b",
+ "b b/b",
+ },
+ modified: []string{},
+ added: []string{
+ "b b/b b/b b/b",
+ "b b/b b/b b/b b/b",
+ },
+ },
+ }
+
+ for _, kase := range kases {
+ fileStatus := NewCommitFileStatus()
+ parseCommitFileStatus(fileStatus, strings.NewReader(kase.output))
+
+ assert.Equal(t, kase.added, fileStatus.Added)
+ assert.Equal(t, kase.removed, fileStatus.Removed)
+ assert.Equal(t, kase.modified, fileStatus.Modified)
+ }
+}
+
+func TestGetCommitFileStatusMerges(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo6_merge")
+
+ commitFileStatus, err := GetCommitFileStatus(DefaultContext, bareRepo1Path, "022f4ce6214973e018f02bf363bf8a2e3691f699")
+ require.NoError(t, err)
+
+ expected := CommitFileStatus{
+ []string{
+ "add_file.txt",
+ },
+ []string{
+ "to_remove.txt",
+ },
+ []string{
+ "to_modify.txt",
+ },
+ }
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+}
+
+func TestParseCommitRenames(t *testing.T) {
+ testcases := []struct {
+ output string
+ renames [][2]string
+ }{
+ {
+ output: "R090\x00renamed.txt\x00history.txt\x00",
+ renames: [][2]string{{"renamed.txt", "history.txt"}},
+ },
+ {
+ output: "R090\x00renamed.txt\x00history.txt\x00R000\x00corruptedstdouthere",
+ renames: [][2]string{{"renamed.txt", "history.txt"}},
+ },
+ {
+ output: "R100\x00renamed.txt\x00history.txt\x00R001\x00readme.md\x00README.md\x00",
+ renames: [][2]string{{"renamed.txt", "history.txt"}, {"readme.md", "README.md"}},
+ },
+ }
+
+ for _, testcase := range testcases {
+ renames := [][2]string{}
+ parseCommitRenames(&renames, strings.NewReader(testcase.output))
+
+ assert.Equal(t, testcase.renames, renames)
+ }
+}
diff --git a/modules/git/diff.go b/modules/git/diff.go
new file mode 100644
index 0000000..10ef3d8
--- /dev/null
+++ b/modules/git/diff.go
@@ -0,0 +1,317 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// RawDiffType type of a raw diff.
+type RawDiffType string
+
+// RawDiffType possible values.
+const (
+ RawDiffNormal RawDiffType = "diff"
+ RawDiffPatch RawDiffType = "patch"
+)
+
+// GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
+func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) error {
+ return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer)
+}
+
+// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
+func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error {
+ stderr := new(bytes.Buffer)
+ cmd := NewCommand(ctx, "show", "--pretty=format:revert %H%n", "-R").AddDynamicArguments(commitID)
+ if err := cmd.Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: writer,
+ Stderr: stderr,
+ }); err != nil {
+ return fmt.Errorf("Run: %w - %s", err, stderr)
+ }
+ return nil
+}
+
+// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository
+func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error {
+ commit, err := repo.GetCommit(endCommit)
+ if err != nil {
+ return err
+ }
+ var files []string
+ if len(file) > 0 {
+ files = append(files, file)
+ }
+
+ cmd := NewCommand(repo.Ctx)
+ switch diffType {
+ case RawDiffNormal:
+ if len(startCommit) != 0 {
+ cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...)
+ } else if commit.ParentCount() == 0 {
+ cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...)
+ } else {
+ c, _ := commit.Parent(0)
+ cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...)
+ }
+ case RawDiffPatch:
+ if len(startCommit) != 0 {
+ query := fmt.Sprintf("%s...%s", endCommit, startCommit)
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(query).AddDashesAndList(files...)
+ } else if commit.ParentCount() == 0 {
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(endCommit).AddDashesAndList(files...)
+ } else {
+ c, _ := commit.Parent(0)
+ query := fmt.Sprintf("%s...%s", endCommit, c.ID.String())
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout").AddDynamicArguments(query).AddDashesAndList(files...)
+ }
+ default:
+ return fmt.Errorf("invalid diffType: %s", diffType)
+ }
+
+ stderr := new(bytes.Buffer)
+ if err = cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: writer,
+ Stderr: stderr,
+ }); err != nil {
+ return fmt.Errorf("Run: %w - %s", err, stderr)
+ }
+ return nil
+}
+
+// ParseDiffHunkString parse the diffhunk content and return
+func ParseDiffHunkString(diffhunk string) (leftLine, leftHunk, rightLine, righHunk int) {
+ ss := strings.Split(diffhunk, "@@")
+ ranges := strings.Split(ss[1][1:], " ")
+ leftRange := strings.Split(ranges[0], ",")
+ leftLine, _ = strconv.Atoi(leftRange[0][1:])
+ if len(leftRange) > 1 {
+ leftHunk, _ = strconv.Atoi(leftRange[1])
+ }
+ if len(ranges) > 1 {
+ rightRange := strings.Split(ranges[1], ",")
+ rightLine, _ = strconv.Atoi(rightRange[0])
+ if len(rightRange) > 1 {
+ righHunk, _ = strconv.Atoi(rightRange[1])
+ }
+ } else {
+ log.Debug("Parse line number failed: %v", diffhunk)
+ rightLine = leftLine
+ righHunk = leftHunk
+ }
+ return leftLine, leftHunk, rightLine, righHunk
+}
+
+// Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9]
+var hunkRegex = regexp.MustCompile(`^@@ -(?P<beginOld>[0-9]+)(,(?P<endOld>[0-9]+))? \+(?P<beginNew>[0-9]+)(,(?P<endNew>[0-9]+))? @@`)
+
+const cmdDiffHead = "diff --git "
+
+func isHeader(lof string, inHunk bool) bool {
+ return strings.HasPrefix(lof, cmdDiffHead) || (!inHunk && (strings.HasPrefix(lof, "---") || strings.HasPrefix(lof, "+++")))
+}
+
+// CutDiffAroundLine cuts a diff of a file in way that only the given line + numberOfLine above it will be shown
+// it also recalculates hunks and adds the appropriate headers to the new diff.
+// Warning: Only one-file diffs are allowed.
+func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLine int) (string, error) {
+ if line == 0 || numbersOfLine == 0 {
+ // no line or num of lines => no diff
+ return "", nil
+ }
+
+ scanner := bufio.NewScanner(originalDiff)
+ hunk := make([]string, 0)
+
+ // begin is the start of the hunk containing searched line
+ // end is the end of the hunk ...
+ // currentLine is the line number on the side of the searched line (differentiated by old)
+ // otherLine is the line number on the opposite side of the searched line (differentiated by old)
+ var begin, end, currentLine, otherLine int64
+ var headerLines int
+
+ inHunk := false
+
+ for scanner.Scan() {
+ lof := scanner.Text()
+ // Add header to enable parsing
+
+ if isHeader(lof, inHunk) {
+ if strings.HasPrefix(lof, cmdDiffHead) {
+ inHunk = false
+ }
+ hunk = append(hunk, lof)
+ headerLines++
+ }
+ if currentLine > line {
+ break
+ }
+ // Detect "hunk" with contains commented lof
+ if strings.HasPrefix(lof, "@@") {
+ inHunk = true
+ // Already got our hunk. End of hunk detected!
+ if len(hunk) > headerLines {
+ break
+ }
+ // A map with named groups of our regex to recognize them later more easily
+ submatches := hunkRegex.FindStringSubmatch(lof)
+ groups := make(map[string]string)
+ for i, name := range hunkRegex.SubexpNames() {
+ if i != 0 && name != "" {
+ groups[name] = submatches[i]
+ }
+ }
+ if old {
+ begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
+ end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
+ // init otherLine with begin of opposite side
+ otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
+ } else {
+ begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
+ if groups["endNew"] != "" {
+ end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
+ } else {
+ end = 0
+ }
+ // init otherLine with begin of opposite side
+ otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
+ }
+ end += begin // end is for real only the number of lines in hunk
+ // lof is between begin and end
+ if begin <= line && end >= line {
+ hunk = append(hunk, lof)
+ currentLine = begin
+ continue
+ }
+ } else if len(hunk) > headerLines {
+ hunk = append(hunk, lof)
+ // Count lines in context
+ switch lof[0] {
+ case '+':
+ if !old {
+ currentLine++
+ } else {
+ otherLine++
+ }
+ case '-':
+ if old {
+ currentLine++
+ } else {
+ otherLine++
+ }
+ case '\\':
+ // FIXME: handle `\ No newline at end of file`
+ default:
+ currentLine++
+ otherLine++
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+
+ // No hunk found
+ if currentLine == 0 {
+ return "", nil
+ }
+ // headerLines + hunkLine (1) = totalNonCodeLines
+ if len(hunk)-headerLines-1 <= numbersOfLine {
+ // No need to cut the hunk => return existing hunk
+ return strings.Join(hunk, "\n"), nil
+ }
+ var oldBegin, oldNumOfLines, newBegin, newNumOfLines int64
+ if old {
+ oldBegin = currentLine
+ newBegin = otherLine
+ } else {
+ oldBegin = otherLine
+ newBegin = currentLine
+ }
+ // headers + hunk header
+ newHunk := make([]string, headerLines)
+ // transfer existing headers
+ copy(newHunk, hunk[:headerLines])
+ // transfer last n lines
+ newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
+ // calculate newBegin, ... by counting lines
+ for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
+ switch hunk[i][0] {
+ case '+':
+ newBegin--
+ newNumOfLines++
+ case '-':
+ oldBegin--
+ oldNumOfLines++
+ default:
+ oldBegin--
+ newBegin--
+ newNumOfLines++
+ oldNumOfLines++
+ }
+ }
+ // construct the new hunk header
+ newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
+ oldBegin, oldNumOfLines, newBegin, newNumOfLines)
+ return strings.Join(newHunk, "\n"), nil
+}
+
+// GetAffectedFiles returns the affected files between two commits
+func GetAffectedFiles(repo *Repository, oldCommitID, newCommitID string, env []string) ([]string, error) {
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ log.Error("Unable to create os.Pipe for %s", repo.Path)
+ return nil, err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ affectedFiles := make([]string, 0, 32)
+
+ // Run `git diff --name-only` to get the names of the changed files
+ err = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID).
+ Run(&RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ // Close the writer end of the pipe to begin processing
+ _ = stdoutWriter.Close()
+ defer func() {
+ // Close the reader on return to terminate the git command if necessary
+ _ = stdoutReader.Close()
+ }()
+ // Now scan the output from the command
+ scanner := bufio.NewScanner(stdoutReader)
+ for scanner.Scan() {
+ path := strings.TrimSpace(scanner.Text())
+ if len(path) == 0 {
+ continue
+ }
+ affectedFiles = append(affectedFiles, path)
+ }
+ return scanner.Err()
+ },
+ })
+ if err != nil {
+ log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
+ }
+
+ return affectedFiles, err
+}
diff --git a/modules/git/diff_test.go b/modules/git/diff_test.go
new file mode 100644
index 0000000..0855a7d
--- /dev/null
+++ b/modules/git/diff_test.go
@@ -0,0 +1,169 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const exampleDiff = `diff --git a/README.md b/README.md
+--- a/README.md
++++ b/README.md
+@@ -1,3 +1,6 @@
+ # gitea-github-migrator
++
++ Build Status
+- Latest Release
+ Docker Pulls
++ cut off
++ cut off`
+
+const breakingDiff = `diff --git a/aaa.sql b/aaa.sql
+index d8e4c92..19dc8ad 100644
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2
++-- some comment 3
+ create or replace procedure test(p1 varchar2)
+ is
+ begin
+---new comment
+ dbms_output.put_line(p1);
++--some other comment
+ end;
+ /
+`
+
+var issue17875Diff = `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
+index d46c152..a7d2d55 100644
+--- a/Geschäftsordnung.md
++++ b/Geschäftsordnung.md
+@@ -1,5 +1,5 @@
+ ---
+-date: "23.01.2021"
++date: "30.11.2021"
+ ...
+ ` + `
+ # Geschäftsordnung
+@@ -16,4 +16,22 @@ Diese Geschäftsordnung regelt alle Prozesse des Vereins, solange diese nicht du
+ ` + `
+ ## § 3 Datenschutzverantwortlichkeit
+ ` + `
+-1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
+\ No newline at end of file
++1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
++
++## §4 Umgang mit der SARS-Cov-2-Pandemie
++
++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.
++
++2. Die Einführung, Änderung und Abschaffung dieser Maßnahmen sind nur zum Zweck der Eindämmung der SARS-Cov-2-Pandemie zulässig.
++
++3. Die Einführung, Änderung und Abschaffung von Maßnahmen nach Abs. 2 bedarf einer wissenschaftlichen Grundlage.
++
++4. Die Maßnahmen nach Abs. 2 setzen sich aus den folgenden Bausteinen inklusive einer ihrer Ausprägungen zusammen.
++
++ 1. Maskenpflicht: Keine; Maskenpflicht, außer am Platz, oder wo Abstände nicht eingehalten werden können; Maskenpflicht, wenn Abstände nicht eingehalten werden können; Maskenpflicht
++
++ 2. Geimpft-, Genesen- oder Testnachweis: Kein Nachweis notwendig; Nachweis, dass Person geimpft, genesen oder tagesaktuell getestet ist (3G); Nachweis, dass Person geimpft oder genesen ist (2G); Nachweis, dass Person geimpft bzw. genesen und tagesaktuell getestet ist (2G+)
++
++ 3. Online-Veranstaltung: Keine, parallele Online-Veranstaltung, ausschließlich Online-Veranstaltung
++
++5. Bei Präsenzveranstungen gelten außerdem die Hygienevorschriften des Veranstaltungsorts. Bei Regelkollision greift die restriktivere Regel.
+\ No newline at end of file`
+
+func TestCutDiffAroundLineIssue17875(t *testing.T) {
+ result, err := CutDiffAroundLine(strings.NewReader(issue17875Diff), 23, false, 3)
+ require.NoError(t, err)
+ expected := `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
+--- a/Geschäftsordnung.md
++++ b/Geschäftsordnung.md
+@@ -20,0 +21,3 @@
++## §4 Umgang mit der SARS-Cov-2-Pandemie
++
++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.`
+ assert.Equal(t, expected, result)
+}
+
+func TestCutDiffAroundLine(t *testing.T) {
+ result, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 4, false, 3)
+ require.NoError(t, err)
+ resultByLine := strings.Split(result, "\n")
+ assert.Len(t, resultByLine, 7)
+ // Check if headers got transferred
+ assert.Equal(t, "diff --git a/README.md b/README.md", resultByLine[0])
+ assert.Equal(t, "--- a/README.md", resultByLine[1])
+ assert.Equal(t, "+++ b/README.md", resultByLine[2])
+ // Check if hunk header is calculated correctly
+ assert.Equal(t, "@@ -2,2 +3,2 @@", resultByLine[3])
+ // Check if line got transferred
+ assert.Equal(t, "+ Build Status", resultByLine[4])
+
+ // Must be same result as before since old line 3 == new line 5
+ newResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
+ require.NoError(t, err)
+ assert.Equal(t, result, newResult, "Must be same result as before since old line 3 == new line 5")
+
+ newResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 300)
+ require.NoError(t, err)
+ assert.Equal(t, exampleDiff, newResult)
+
+ emptyResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 0)
+ require.NoError(t, err)
+ assert.Empty(t, emptyResult)
+
+ // Line is out of scope
+ emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
+ require.NoError(t, err)
+ assert.Empty(t, emptyResult)
+
+ // Handle minus diffs properly
+ minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
+ require.NoError(t, err)
+
+ expected := `diff --git a/aaa.sql b/aaa.sql
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2`
+ assert.Equal(t, expected, minusDiff)
+
+ // Handle minus diffs properly
+ minusDiff, err = CutDiffAroundLine(strings.NewReader(breakingDiff), 3, false, 4)
+ require.NoError(t, err)
+
+ expected = `diff --git a/aaa.sql b/aaa.sql
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2
++-- some comment 3`
+
+ assert.Equal(t, expected, minusDiff)
+}
+
+func BenchmarkCutDiffAroundLine(b *testing.B) {
+ for n := 0; n < b.N; n++ {
+ CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
+ }
+}
+
+func TestParseDiffHunkString(t *testing.T) {
+ leftLine, leftHunk, rightLine, rightHunk := ParseDiffHunkString("@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER")
+ assert.EqualValues(t, 19, leftLine)
+ assert.EqualValues(t, 3, leftHunk)
+ assert.EqualValues(t, 19, rightLine)
+ assert.EqualValues(t, 5, rightHunk)
+}
diff --git a/modules/git/error.go b/modules/git/error.go
new file mode 100644
index 0000000..91d25ec
--- /dev/null
+++ b/modules/git/error.go
@@ -0,0 +1,187 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ErrExecTimeout error when exec timed out
+type ErrExecTimeout struct {
+ Duration time.Duration
+}
+
+// IsErrExecTimeout if some error is ErrExecTimeout
+func IsErrExecTimeout(err error) bool {
+ _, ok := err.(ErrExecTimeout)
+ return ok
+}
+
+func (err ErrExecTimeout) Error() string {
+ return fmt.Sprintf("execution is timeout [duration: %v]", err.Duration)
+}
+
+// ErrNotExist commit not exist error
+type ErrNotExist struct {
+ ID string
+ RelPath string
+}
+
+// IsErrNotExist if some error is ErrNotExist
+func IsErrNotExist(err error) bool {
+ _, ok := err.(ErrNotExist)
+ return ok
+}
+
+func (err ErrNotExist) Error() string {
+ return fmt.Sprintf("object does not exist [id: %s, rel_path: %s]", err.ID, err.RelPath)
+}
+
+func (err ErrNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrBadLink entry.FollowLink error
+type ErrBadLink struct {
+ Name string
+ Message string
+}
+
+func (err ErrBadLink) Error() string {
+ return fmt.Sprintf("%s: %s", err.Name, err.Message)
+}
+
+// IsErrBadLink if some error is ErrBadLink
+func IsErrBadLink(err error) bool {
+ _, ok := err.(ErrBadLink)
+ return ok
+}
+
+// ErrUnsupportedVersion error when required git version not matched
+type ErrUnsupportedVersion struct {
+ Required string
+}
+
+// IsErrUnsupportedVersion if some error is ErrUnsupportedVersion
+func IsErrUnsupportedVersion(err error) bool {
+ _, ok := err.(ErrUnsupportedVersion)
+ return ok
+}
+
+func (err ErrUnsupportedVersion) Error() string {
+ return fmt.Sprintf("Operation requires higher version [required: %s]", err.Required)
+}
+
+// ErrBranchNotExist represents a "BranchNotExist" kind of error.
+type ErrBranchNotExist struct {
+ Name string
+}
+
+// IsErrBranchNotExist checks if an error is a ErrBranchNotExist.
+func IsErrBranchNotExist(err error) bool {
+ _, ok := err.(ErrBranchNotExist)
+ return ok
+}
+
+func (err ErrBranchNotExist) Error() string {
+ return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
+}
+
+func (err ErrBranchNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrPushOutOfDate represents an error if merging fails due to the base branch being updated
+type ErrPushOutOfDate struct {
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrPushOutOfDate checks if an error is a ErrPushOutOfDate.
+func IsErrPushOutOfDate(err error) bool {
+ _, ok := err.(*ErrPushOutOfDate)
+ return ok
+}
+
+func (err *ErrPushOutOfDate) Error() string {
+ return fmt.Sprintf("PushOutOfDate Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
+
+// Unwrap unwraps the underlying error
+func (err *ErrPushOutOfDate) Unwrap() error {
+ return fmt.Errorf("%w - %s", err.Err, err.StdErr)
+}
+
+// ErrPushRejected represents an error if merging fails due to rejection from a hook
+type ErrPushRejected struct {
+ Message string
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrPushRejected checks if an error is a ErrPushRejected.
+func IsErrPushRejected(err error) bool {
+ _, ok := err.(*ErrPushRejected)
+ return ok
+}
+
+func (err *ErrPushRejected) Error() string {
+ return fmt.Sprintf("PushRejected Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
+
+// Unwrap unwraps the underlying error
+func (err *ErrPushRejected) Unwrap() error {
+ return fmt.Errorf("%w - %s", err.Err, err.StdErr)
+}
+
+// GenerateMessage generates the remote message from the stderr
+func (err *ErrPushRejected) GenerateMessage() {
+ messageBuilder := &strings.Builder{}
+ i := strings.Index(err.StdErr, "remote: ")
+ if i < 0 {
+ err.Message = ""
+ return
+ }
+ for {
+ if len(err.StdErr) <= i+8 {
+ break
+ }
+ if err.StdErr[i:i+8] != "remote: " {
+ break
+ }
+ i += 8
+ nl := strings.IndexByte(err.StdErr[i:], '\n')
+ if nl >= 0 {
+ messageBuilder.WriteString(err.StdErr[i : i+nl+1])
+ i = i + nl + 1
+ } else {
+ messageBuilder.WriteString(err.StdErr[i:])
+ i = len(err.StdErr)
+ }
+ }
+ err.Message = strings.TrimSpace(messageBuilder.String())
+}
+
+// ErrMoreThanOne represents an error if pull request fails when there are more than one sources (branch, tag) with the same name
+type ErrMoreThanOne struct {
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrMoreThanOne checks if an error is a ErrMoreThanOne
+func IsErrMoreThanOne(err error) bool {
+ _, ok := err.(*ErrMoreThanOne)
+ return ok
+}
+
+func (err *ErrMoreThanOne) Error() string {
+ return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
diff --git a/modules/git/foreachref/format.go b/modules/git/foreachref/format.go
new file mode 100644
index 0000000..97e8ee4
--- /dev/null
+++ b/modules/git/foreachref/format.go
@@ -0,0 +1,83 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref
+
+import (
+ "encoding/hex"
+ "fmt"
+ "io"
+ "strings"
+)
+
+var (
+ nullChar = []byte("\x00")
+ dualNullChar = []byte("\x00\x00")
+)
+
+// Format supports specifying and parsing an output format for 'git
+// for-each-ref'. See See git-for-each-ref(1) for available fields.
+type Format struct {
+ // fieldNames hold %(fieldname)s to be passed to the '--format' flag of
+ // for-each-ref. See git-for-each-ref(1) for available fields.
+ fieldNames []string
+
+ // fieldDelim is the character sequence that is used to separate fields
+ // for each reference. fieldDelim and refDelim should be selected to not
+ // interfere with each other and to not be present in field values.
+ fieldDelim []byte
+ // fieldDelimStr is a string representation of fieldDelim. Used to save
+ // us from repetitive reallocation whenever we need the delimiter as a
+ // string.
+ fieldDelimStr string
+ // refDelim is the character sequence used to separate reference from
+ // each other in the output. fieldDelim and refDelim should be selected
+ // to not interfere with each other and to not be present in field
+ // values.
+ refDelim []byte
+}
+
+// NewFormat creates a forEachRefFormat using the specified fieldNames. See
+// git-for-each-ref(1) for available fields.
+func NewFormat(fieldNames ...string) Format {
+ return Format{
+ fieldNames: fieldNames,
+ fieldDelim: nullChar,
+ fieldDelimStr: string(nullChar),
+ refDelim: dualNullChar,
+ }
+}
+
+// Flag returns a for-each-ref --format flag value that captures the fieldNames.
+func (f Format) Flag() string {
+ var formatFlag strings.Builder
+ for i, field := range f.fieldNames {
+ // field key and field value
+ formatFlag.WriteString(fmt.Sprintf("%s %%(%s)", field, field))
+
+ if i < len(f.fieldNames)-1 {
+ // note: escape delimiters to allow control characters as
+ // delimiters. For example, '%00' for null character or '%0a'
+ // for newline.
+ formatFlag.WriteString(f.hexEscaped(f.fieldDelim))
+ }
+ }
+ formatFlag.WriteString(f.hexEscaped(f.refDelim))
+ return formatFlag.String()
+}
+
+// Parser returns a Parser capable of parsing 'git for-each-ref' output produced
+// with this Format.
+func (f Format) Parser(r io.Reader) *Parser {
+ return NewParser(r, f)
+}
+
+// hexEscaped produces hex-escpaed characters from a string. For example, "\n\0"
+// would turn into "%0a%00".
+func (f Format) hexEscaped(delim []byte) string {
+ escaped := ""
+ for i := 0; i < len(delim); i++ {
+ escaped += "%" + hex.EncodeToString([]byte{delim[i]})
+ }
+ return escaped
+}
diff --git a/modules/git/foreachref/format_test.go b/modules/git/foreachref/format_test.go
new file mode 100644
index 0000000..8ff2393
--- /dev/null
+++ b/modules/git/foreachref/format_test.go
@@ -0,0 +1,66 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFormat_Flag(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenFormat foreachref.Format
+
+ wantFlag string
+ }{
+ {
+ name: "references are delimited by dual null chars",
+
+ // no reference fields requested
+ givenFormat: foreachref.NewFormat(),
+
+ // only a reference delimiter field in --format
+ wantFlag: "%00%00",
+ },
+
+ {
+ name: "a field is a space-separated key-value pair",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+
+ // only a reference delimiter field
+ wantFlag: "refname:short %(refname:short)%00%00",
+ },
+
+ {
+ name: "fields are separated by a null char field-delimiter",
+
+ givenFormat: foreachref.NewFormat("refname:short", "author"),
+
+ wantFlag: "refname:short %(refname:short)%00author %(author)%00%00",
+ },
+
+ {
+ name: "multiple fields",
+
+ givenFormat: foreachref.NewFormat("refname:lstrip=2", "objecttype", "objectname"),
+
+ wantFlag: "refname:lstrip=2 %(refname:lstrip=2)%00objecttype %(objecttype)%00objectname %(objectname)%00%00",
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ gotFlag := tc.givenFormat.Flag()
+
+ require.Equal(t, tc.wantFlag, gotFlag, "unexpected for-each-ref --format string. wanted: '%s', got: '%s'", tc.wantFlag, gotFlag)
+ })
+ }
+}
diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go
new file mode 100644
index 0000000..de69eaa
--- /dev/null
+++ b/modules/git/foreachref/parser.go
@@ -0,0 +1,128 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// Parser parses 'git for-each-ref' output according to a given output Format.
+type Parser struct {
+ // tokenizes 'git for-each-ref' output into "reference paragraphs".
+ scanner *bufio.Scanner
+
+ // format represents the '--format' string that describes the expected
+ // 'git for-each-ref' output structure.
+ format Format
+
+ // err holds the last encountered error during parsing.
+ err error
+}
+
+// NewParser creates a 'git for-each-ref' output parser that will parse all
+// references in the provided Reader. The references in the output are assumed
+// to follow the specified Format.
+func NewParser(r io.Reader, format Format) *Parser {
+ scanner := bufio.NewScanner(r)
+
+ // in addition to the reference delimiter we specified in the --format,
+ // `git for-each-ref` will always add a newline after every reference.
+ refDelim := make([]byte, 0, len(format.refDelim)+1)
+ refDelim = append(refDelim, format.refDelim...)
+ refDelim = append(refDelim, '\n')
+
+ // Split input into delimiter-separated "reference blocks".
+ scanner.Split(
+ func(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ // Scan until delimiter, marking end of reference.
+ delimIdx := bytes.Index(data, refDelim)
+ if delimIdx >= 0 {
+ token := data[:delimIdx]
+ advance := delimIdx + len(refDelim)
+ return advance, token, nil
+ }
+ // If we're at EOF, we have a final, non-terminated reference. Return it.
+ if atEOF {
+ return len(data), data, nil
+ }
+ // Not yet a full field. Request more data.
+ return 0, nil, nil
+ })
+
+ return &Parser{
+ scanner: scanner,
+ format: format,
+ err: nil,
+ }
+}
+
+// Next returns the next reference as a collection of key-value pairs. nil
+// denotes EOF but is also returned on errors. The Err method should always be
+// consulted after Next returning nil.
+//
+// It could, for example return something like:
+//
+// { "objecttype": "tag", "refname:short": "v1.16.4", "object": "f460b7543ed500e49c133c2cd85c8c55ee9dbe27" }
+func (p *Parser) Next() map[string]string {
+ if !p.scanner.Scan() {
+ return nil
+ }
+ fields, err := p.parseRef(p.scanner.Text())
+ if err != nil {
+ p.err = err
+ return nil
+ }
+ return fields
+}
+
+// Err returns the latest encountered parsing error.
+func (p *Parser) Err() error {
+ return p.err
+}
+
+// parseRef parses out all key-value pairs from a single reference block, such as
+//
+// "objecttype tag\0refname:short v1.16.4\0object f460b7543ed500e49c133c2cd85c8c55ee9dbe27"
+func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
+ if refBlock == "" {
+ // must be at EOF
+ return nil, nil
+ }
+
+ fieldValues := make(map[string]string)
+
+ fields := strings.Split(refBlock, p.format.fieldDelimStr)
+ if len(fields) != len(p.format.fieldNames) {
+ return nil, fmt.Errorf("unexpected number of reference fields: wanted %d, was %d",
+ len(fields), len(p.format.fieldNames))
+ }
+ for i, field := range fields {
+ field = strings.TrimSpace(field)
+
+ var fieldKey string
+ var fieldVal string
+ firstSpace := strings.Index(field, " ")
+ if firstSpace > 0 {
+ fieldKey = field[:firstSpace]
+ fieldVal = field[firstSpace+1:]
+ } else {
+ // could be the case if the requested field had no value
+ fieldKey = field
+ }
+
+ // enforce the format order of fields
+ if p.format.fieldNames[i] != fieldKey {
+ return nil, fmt.Errorf("unexpected field name at position %d: wanted: '%s', was: '%s'",
+ i, p.format.fieldNames[i], fieldKey)
+ }
+
+ fieldValues[fieldKey] = fieldVal
+ }
+
+ return fieldValues, nil
+}
diff --git a/modules/git/foreachref/parser_test.go b/modules/git/foreachref/parser_test.go
new file mode 100644
index 0000000..7a37ced
--- /dev/null
+++ b/modules/git/foreachref/parser_test.go
@@ -0,0 +1,227 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref_test
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/require"
+)
+
+type refSlice = []map[string]string
+
+func TestParser(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenFormat foreachref.Format
+ givenInput io.Reader
+
+ wantRefs refSlice
+ wantErr bool
+ expectedErr error
+ }{
+ // this would, for example, be the result when running `git
+ // for-each-ref refs/tags` on a repo without tags.
+ {
+ name: "no references on empty input",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader(``),
+
+ wantRefs: []map[string]string{},
+ },
+
+ // note: `git for-each-ref` will add a newline between every
+ // reference (in addition to the ref-delimiter we've chosen)
+ {
+ name: "single field requested, single reference in output",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader("refname:short v0.0.1\x00\x00" + "\n"),
+
+ wantRefs: []map[string]string{
+ {"refname:short": "v0.0.1"},
+ },
+ },
+ {
+ name: "single field requested, multiple references in output",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00\x00" + "\n"),
+
+ wantRefs: []map[string]string{
+ {"refname:short": "v0.0.1"},
+ {"refname:short": "v0.0.2"},
+ {"refname:short": "v0.0.3"},
+ },
+ },
+
+ {
+ name: "multiple fields requested for each reference",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
+ givenInput: strings.NewReader(
+
+ "refname:short v0.0.1\x00objecttype commit\x00objectname 7b2c5ac9fc04fc5efafb60700713d4fa609b777b\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00objectname a1f051bc3eba734da4772d60e2d677f47cf93ef4\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00objectname ef82de70bb3f60c65fb8eebacbb2d122ef517385\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "objecttype": "commit",
+ "objectname": "7b2c5ac9fc04fc5efafb60700713d4fa609b777b",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "objecttype": "commit",
+ "objectname": "a1f051bc3eba734da4772d60e2d677f47cf93ef4",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "objecttype": "commit",
+ "objectname": "ef82de70bb3f60c65fb8eebacbb2d122ef517385",
+ },
+ },
+ },
+
+ {
+ name: "must handle multi-line fields such as 'content'",
+
+ givenFormat: foreachref.NewFormat("refname:short", "contents", "author"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00contents Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.\x00author Foo Bar <foo@bar.com> 1507832733 +0200\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00contents Update CI config (#651)\n\n\x00author John Doe <john.doe@foo.com> 1521643174 +0000\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00contents Fixed code sample for bash completion (#687)\n\n\x00author Foo Baz <foo@baz.com> 1524836750 +0200\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "contents": "Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.",
+ "author": "Foo Bar <foo@bar.com> 1507832733 +0200",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "contents": "Update CI config (#651)",
+ "author": "John Doe <john.doe@foo.com> 1521643174 +0000",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "contents": "Fixed code sample for bash completion (#687)",
+ "author": "Foo Baz <foo@baz.com> 1524836750 +0200",
+ },
+ },
+ },
+
+ {
+ name: "must handle fields without values",
+
+ givenFormat: foreachref.NewFormat("refname:short", "object", "objecttype"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00object \x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00object \x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00object \x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "object": "",
+ "objecttype": "commit",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "object": "",
+ "objecttype": "commit",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "object": "",
+ "objecttype": "commit",
+ },
+ },
+ },
+
+ {
+ name: "must fail when the number of fields in the input doesn't match expected format",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantErr: true,
+ expectedErr: errors.New("unexpected number of reference fields: wanted 2, was 3"),
+ },
+
+ {
+ name: "must fail input fields don't match expected format",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objectname"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantErr: true,
+ expectedErr: errors.New("unexpected field name at position 1: wanted: 'objectname', was: 'objecttype'"),
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ parser := tc.givenFormat.Parser(tc.givenInput)
+
+ //
+ // parse references from input
+ //
+ gotRefs := make([]map[string]string, 0)
+ for {
+ ref := parser.Next()
+ if ref == nil {
+ break
+ }
+ gotRefs = append(gotRefs, ref)
+ }
+ err := parser.Err()
+
+ //
+ // verify expectations
+ //
+ if tc.wantErr {
+ require.Error(t, err)
+ require.EqualError(t, err, tc.expectedErr.Error())
+ } else {
+ require.NoError(t, err, "for-each-ref parser unexpectedly failed with: %v", err)
+ require.Equal(t, tc.wantRefs, gotRefs, "for-each-ref parser produced unexpected reference set. wanted: %v, got: %v", pretty(tc.wantRefs), pretty(gotRefs))
+ }
+ })
+ }
+}
+
+func pretty(v any) string {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ // shouldn't happen
+ panic(fmt.Sprintf("json-marshalling failed: %v", err))
+ }
+ return string(data)
+}
diff --git a/modules/git/git.go b/modules/git/git.go
new file mode 100644
index 0000000..f1174e6
--- /dev/null
+++ b/modules/git/git.go
@@ -0,0 +1,422 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/hashicorp/go-version"
+)
+
+// RequiredVersion is the minimum Git version required
+const RequiredVersion = "2.0.0"
+
+var (
+ // GitExecutable is the command name of git
+ // Could be updated to an absolute path while initialization
+ GitExecutable = "git"
+
+ // DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx
+ DefaultContext context.Context
+
+ SupportProcReceive bool // >= 2.29
+ SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
+ InvertedGitFlushEnv bool // 2.43.1
+ SupportCheckAttrOnBare bool // >= 2.40
+
+ HasSSHExecutable bool
+
+ gitVersion *version.Version
+)
+
+// loadGitVersion returns current Git version from shell. Internal usage only.
+func loadGitVersion() error {
+ // doesn't need RWMutex because it's executed by Init()
+ if gitVersion != nil {
+ return nil
+ }
+ stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil)
+ if runErr != nil {
+ return runErr
+ }
+
+ fields := strings.Fields(stdout)
+ if len(fields) < 3 {
+ return fmt.Errorf("invalid git version output: %s", stdout)
+ }
+
+ var versionString string
+
+ // Handle special case on Windows.
+ i := strings.Index(fields[2], "windows")
+ if i >= 1 {
+ versionString = fields[2][:i-1]
+ } else {
+ versionString = fields[2]
+ }
+
+ var err error
+ gitVersion, err = version.NewVersion(versionString)
+ return err
+}
+
+// SetExecutablePath changes the path of git executable and checks the file permission and version.
+func SetExecutablePath(path string) error {
+ // If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
+ if path != "" {
+ GitExecutable = path
+ }
+ absPath, err := exec.LookPath(GitExecutable)
+ if err != nil {
+ return fmt.Errorf("git not found: %w", err)
+ }
+ GitExecutable = absPath
+
+ err = loadGitVersion()
+ if err != nil {
+ return fmt.Errorf("unable to load git version: %w", err)
+ }
+
+ versionRequired, err := version.NewVersion(RequiredVersion)
+ if err != nil {
+ return err
+ }
+
+ if gitVersion.LessThan(versionRequired) {
+ moreHint := "get git: https://git-scm.com/downloads"
+ if runtime.GOOS == "linux" {
+ // there are a lot of CentOS/RHEL users using old git, so we add a special hint for them
+ if _, err = os.Stat("/etc/redhat-release"); err == nil {
+ // ius.io is the recommended official(git-scm.com) method to install git
+ moreHint = "get git: https://git-scm.com/downloads/linux and https://ius.io"
+ }
+ }
+ return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", gitVersion.Original(), RequiredVersion, moreHint)
+ }
+
+ return nil
+}
+
+// VersionInfo returns git version information
+func VersionInfo() string {
+ if gitVersion == nil {
+ return "(git not found)"
+ }
+ format := "%s"
+ args := []any{gitVersion.Original()}
+ // Since git wire protocol has been released from git v2.18
+ if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
+ format += ", Wire Protocol %s Enabled"
+ args = append(args, "Version 2") // for focus color
+ }
+
+ return fmt.Sprintf(format, args...)
+}
+
+func checkInit() error {
+ if setting.Git.HomePath == "" {
+ return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
+ }
+ if DefaultContext != nil {
+ log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
+ }
+ return nil
+}
+
+// HomeDir is the home dir for git to store the global config file used by Gitea internally
+func HomeDir() string {
+ if setting.Git.HomePath == "" {
+ // strict check, make sure the git module is initialized correctly.
+ // attention: when the git module is called in gitea sub-command (serv/hook), the log module might not obviously show messages to users/developers.
+ // for example: if there is gitea git hook code calling git.NewCommand before git.InitXxx, the integration test won't show the real failure reasons.
+ log.Fatal("Unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
+ return ""
+ }
+ return setting.Git.HomePath
+}
+
+// InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
+// This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands.
+func InitSimple(ctx context.Context) error {
+ if err := checkInit(); err != nil {
+ return err
+ }
+
+ DefaultContext = ctx
+ globalCommandArgs = nil
+
+ if setting.Git.Timeout.Default > 0 {
+ defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
+ }
+
+ return SetExecutablePath(setting.Git.Path)
+}
+
+// InitFull initializes git module with version check and change global variables, sync gitconfig.
+// It should only be called once at the beginning of the program initialization (TestMain/GlobalInitInstalled) as this code makes unsynchronized changes to variables.
+func InitFull(ctx context.Context) (err error) {
+ if err = InitSimple(ctx); err != nil {
+ return err
+ }
+
+ // when git works with gnupg (commit signing), there should be a stable home for gnupg commands
+ if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
+ _ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg"))
+ }
+
+ // Since git wire protocol has been released from git v2.18
+ if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
+ globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2")
+ }
+
+ // Explicitly disable credential helper, otherwise Git credentials might leak
+ if CheckGitVersionAtLeast("2.9") == nil {
+ globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=")
+ }
+ SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil
+ SupportHashSha256 = CheckGitVersionAtLeast("2.42") == nil
+ SupportCheckAttrOnBare = CheckGitVersionAtLeast("2.40") == nil
+ if SupportHashSha256 {
+ SupportedObjectFormats = append(SupportedObjectFormats, Sha256ObjectFormat)
+ } else {
+ log.Warn("sha256 hash support is disabled - requires Git >= 2.42")
+ }
+
+ InvertedGitFlushEnv = CheckGitVersionEqual("2.43.1") == nil
+
+ if setting.LFS.StartServer {
+ if CheckGitVersionAtLeast("2.1.2") != nil {
+ return errors.New("LFS server support requires Git >= 2.1.2")
+ }
+ globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=")
+ }
+
+ // Detect the presence of the ssh executable in $PATH.
+ _, err = exec.LookPath("ssh")
+ HasSSHExecutable = err == nil
+
+ return syncGitConfig()
+}
+
+// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
+func syncGitConfig() (err error) {
+ if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil {
+ return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
+ }
+
+ // first, write user's git config options to git config file
+ // user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
+ for k, v := range setting.GitConfig.Options {
+ if err = configSet(strings.ToLower(k), v); err != nil {
+ return err
+ }
+ }
+
+ // Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
+ // TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
+ // If these values are not really used, then they can be set (overwritten) directly without considering about existence.
+ for configKey, defaultValue := range map[string]string{
+ "user.name": "Gitea",
+ "user.email": "gitea@fake.local",
+ } {
+ if err := configSetNonExist(configKey, defaultValue); err != nil {
+ return err
+ }
+ }
+
+ // Set git some configurations - these must be set to these values for gitea to work correctly
+ if err := configSet("core.quotePath", "false"); err != nil {
+ return err
+ }
+
+ if CheckGitVersionAtLeast("2.10") == nil {
+ if err := configSet("receive.advertisePushOptions", "true"); err != nil {
+ return err
+ }
+ }
+
+ if CheckGitVersionAtLeast("2.18") == nil {
+ if err := configSet("core.commitGraph", "true"); err != nil {
+ return err
+ }
+ if err := configSet("gc.writeCommitGraph", "true"); err != nil {
+ return err
+ }
+ if err := configSet("fetch.writeCommitGraph", "true"); err != nil {
+ return err
+ }
+ }
+
+ if SupportProcReceive {
+ // set support for AGit flow
+ if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
+ return err
+ }
+ } else {
+ if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil {
+ return err
+ }
+ }
+
+ // Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user
+ // however, some docker users and samba users find it difficult to configure their systems so that Gitea's git repositories are owned by the Gitea user. (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
+ // see issue: https://github.com/go-gitea/gitea/issues/19455
+ // Fundamentally the problem lies with the uid-gid-mapping mechanism for filesystems in docker on windows (and to a lesser extent samba).
+ // Docker's configuration mechanism for local filesystems provides no way of setting this mapping and although there is a mechanism for setting this uid through using cifs mounting it is complicated and essentially undocumented
+ // Thus the owner uid/gid for files on these filesystems will be marked as root.
+ // As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
+ // it is now safe to set "safe.directory=*" for internal usage only.
+ // Please note: the wildcard "*" is only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later
+ // Although only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later - this setting is tolerated by earlier versions
+ if err := configAddNonExist("safe.directory", "*"); err != nil {
+ return err
+ }
+ if runtime.GOOS == "windows" {
+ if err := configSet("core.longpaths", "true"); err != nil {
+ return err
+ }
+ if setting.Git.DisableCoreProtectNTFS {
+ err = configSet("core.protectNTFS", "false")
+ } else {
+ err = configUnsetAll("core.protectNTFS", "false")
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ // By default partial clones are disabled, enable them from git v2.22
+ if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil {
+ if err = configSet("uploadpack.allowfilter", "true"); err != nil {
+ return err
+ }
+ err = configSet("uploadpack.allowAnySHA1InWant", "true")
+ } else {
+ if err = configUnsetAll("uploadpack.allowfilter", "true"); err != nil {
+ return err
+ }
+ err = configUnsetAll("uploadpack.allowAnySHA1InWant", "true")
+ }
+
+ return err
+}
+
+// CheckGitVersionAtLeast check git version is at least the constraint version
+func CheckGitVersionAtLeast(atLeast string) error {
+ if err := loadGitVersion(); err != nil {
+ return err
+ }
+ atLeastVersion, err := version.NewVersion(atLeast)
+ if err != nil {
+ return err
+ }
+ if gitVersion.Compare(atLeastVersion) < 0 {
+ return fmt.Errorf("installed git binary version %s is not at least %s", gitVersion.Original(), atLeast)
+ }
+ return nil
+}
+
+// CheckGitVersionEqual checks if the git version is equal to the constraint version.
+func CheckGitVersionEqual(equal string) error {
+ if err := loadGitVersion(); err != nil {
+ return err
+ }
+ atLeastVersion, err := version.NewVersion(equal)
+ if err != nil {
+ return err
+ }
+ if !gitVersion.Equal(atLeastVersion) {
+ return fmt.Errorf("installed git binary version %s is not equal to %s", gitVersion.Original(), equal)
+ }
+ return nil
+}
+
+func configSet(key, value string) error {
+ stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err != nil && !IsErrorExitCode(err, 1) {
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+ }
+
+ currValue := strings.TrimSpace(stdout)
+ if currValue == value {
+ return nil
+ }
+
+ _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
+ }
+
+ return nil
+}
+
+func configSetNonExist(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err == nil {
+ // already exist
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist, set new config
+ _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+func configAddNonExist(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
+ if err == nil {
+ // already exist
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist, add new config
+ _, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+func configUnsetAll(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err == nil {
+ // exist, need to remove
+ _, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist
+ return nil
+ }
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+// Fsck verifies the connectivity and validity of the objects in the database
+func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error {
+ return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
+}
diff --git a/modules/git/git_test.go b/modules/git/git_test.go
new file mode 100644
index 0000000..cdbd2a1
--- /dev/null
+++ b/modules/git/git_test.go
@@ -0,0 +1,96 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testRun(m *testing.M) error {
+ gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
+ if err != nil {
+ return fmt.Errorf("unable to create temp dir: %w", err)
+ }
+ defer util.RemoveAll(gitHomePath)
+ setting.Git.HomePath = gitHomePath
+
+ if err = InitFull(context.Background()); err != nil {
+ return fmt.Errorf("failed to call Init: %w", err)
+ }
+
+ exitCode := m.Run()
+ if exitCode != 0 {
+ return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
+ }
+ return nil
+}
+
+func TestMain(m *testing.M) {
+ if err := testRun(m); err != nil {
+ _, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
+ os.Exit(1)
+ }
+}
+
+func gitConfigContains(sub string) bool {
+ if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
+ return strings.Contains(string(b), sub)
+ }
+ return false
+}
+
+func TestGitConfig(t *testing.T) {
+ assert.False(t, gitConfigContains("key-a"))
+
+ require.NoError(t, configSetNonExist("test.key-a", "val-a"))
+ assert.True(t, gitConfigContains("key-a = val-a"))
+
+ require.NoError(t, configSetNonExist("test.key-a", "val-a-changed"))
+ assert.False(t, gitConfigContains("key-a = val-a-changed"))
+
+ require.NoError(t, configSet("test.key-a", "val-a-changed"))
+ assert.True(t, gitConfigContains("key-a = val-a-changed"))
+
+ require.NoError(t, configAddNonExist("test.key-b", "val-b"))
+ assert.True(t, gitConfigContains("key-b = val-b"))
+
+ require.NoError(t, configAddNonExist("test.key-b", "val-2b"))
+ assert.True(t, gitConfigContains("key-b = val-b"))
+ assert.True(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configUnsetAll("test.key-b", "val-b"))
+ assert.False(t, gitConfigContains("key-b = val-b"))
+ assert.True(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configUnsetAll("test.key-b", "val-2b"))
+ assert.False(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configSet("test.key-x", "*"))
+ assert.True(t, gitConfigContains("key-x = *"))
+ require.NoError(t, configSetNonExist("test.key-x", "*"))
+ require.NoError(t, configUnsetAll("test.key-x", "*"))
+ assert.False(t, gitConfigContains("key-x = *"))
+}
+
+func TestSyncConfig(t *testing.T) {
+ oldGitConfig := setting.GitConfig
+ defer func() {
+ setting.GitConfig = oldGitConfig
+ }()
+
+ setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
+ require.NoError(t, syncGitConfig())
+ assert.True(t, gitConfigContains("[sync-test]"))
+ assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
+}
diff --git a/modules/git/grep.go b/modules/git/grep.go
new file mode 100644
index 0000000..5572bd9
--- /dev/null
+++ b/modules/git/grep.go
@@ -0,0 +1,187 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type GrepResult struct {
+ Filename string
+ LineNumbers []int
+ LineCodes []string
+ HighlightedRanges [][3]int
+}
+
+type GrepOptions struct {
+ RefName string
+ MaxResultLimit int
+ MatchesPerFile int
+ ContextLineNumber int
+ IsFuzzy bool
+ PathSpec []setting.Glob
+}
+
+func (opts *GrepOptions) ensureDefaults() {
+ opts.RefName = cmp.Or(opts.RefName, "HEAD")
+ opts.MaxResultLimit = cmp.Or(opts.MaxResultLimit, 50)
+ opts.MatchesPerFile = cmp.Or(opts.MatchesPerFile, 20)
+}
+
+func hasPrefixFold(s, t string) bool {
+ if len(s) < len(t) {
+ return false
+ }
+ return strings.EqualFold(s[:len(t)], t)
+}
+
+func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, fmt.Errorf("unable to create os pipe to grep: %w", err)
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ opts.ensureDefaults()
+
+ /*
+ The output is like this ("^@" means \x00; the first number denotes the line,
+ the second number denotes the column of the first match in line):
+
+ HEAD:.air.toml
+ 6^@8^@bin = "gitea"
+
+ HEAD:.changelog.yml
+ 2^@10^@repo: go-gitea/gitea
+ */
+ var results []*GrepResult
+ // -I skips binary files
+ cmd := NewCommand(ctx, "grep",
+ "-I", "--null", "--break", "--heading", "--column",
+ "--fixed-strings", "--line-number", "--ignore-case", "--full-name")
+ cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber))
+ cmd.AddOptionValues("--max-count", fmt.Sprint(opts.MatchesPerFile))
+ words := []string{search}
+ if opts.IsFuzzy {
+ words = strings.Fields(search)
+ }
+ for _, word := range words {
+ cmd.AddGitGrepExpression(word)
+ }
+
+ // pathspec
+ files := make([]string, 0,
+ len(setting.Indexer.IncludePatterns)+
+ len(setting.Indexer.ExcludePatterns)+
+ len(opts.PathSpec))
+ for _, expr := range append(setting.Indexer.IncludePatterns, opts.PathSpec...) {
+ files = append(files, ":"+expr.Pattern())
+ }
+ for _, expr := range setting.Indexer.ExcludePatterns {
+ files = append(files, ":^"+expr.Pattern())
+ }
+ cmd.AddDynamicArguments(opts.RefName).AddDashesAndList(files...)
+
+ stderr := bytes.Buffer{}
+ err = cmd.Run(&RunOpts{
+ Timeout: time.Duration(setting.Git.Timeout.Grep) * time.Second,
+
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ defer stdoutReader.Close()
+
+ isInBlock := false
+ scanner := bufio.NewReader(stdoutReader)
+ var res *GrepResult
+ for {
+ line, err := scanner.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ return nil
+ }
+ return err
+ }
+ // Remove delimiter.
+ if len(line) > 0 {
+ line = line[:len(line)-1]
+ }
+
+ if !isInBlock {
+ if _ /* ref */, filename, ok := strings.Cut(line, ":"); ok {
+ isInBlock = true
+ res = &GrepResult{Filename: filename}
+ results = append(results, res)
+ }
+ continue
+ }
+ if line == "" {
+ if len(results) >= opts.MaxResultLimit {
+ cancel()
+ break
+ }
+ isInBlock = false
+ continue
+ }
+ if line == "--" {
+ continue
+ }
+ if lineNum, lineCode, ok := strings.Cut(line, "\x00"); ok {
+ lineNumInt, _ := strconv.Atoi(lineNum)
+ res.LineNumbers = append(res.LineNumbers, lineNumInt)
+ if lineCol, lineCode2, ok := strings.Cut(lineCode, "\x00"); ok {
+ lineColInt, _ := strconv.Atoi(lineCol)
+ start := lineColInt - 1
+ matchLen := len(lineCode2)
+ for _, word := range words {
+ if hasPrefixFold(lineCode2[start:], word) {
+ matchLen = len(word)
+ break
+ }
+ }
+ res.HighlightedRanges = append(res.HighlightedRanges, [3]int{
+ len(res.LineCodes),
+ start,
+ start + matchLen,
+ })
+ res.LineCodes = append(res.LineCodes, lineCode2)
+ continue
+ }
+ res.LineCodes = append(res.LineCodes, lineCode)
+ }
+ }
+ return nil
+ },
+ })
+ // git grep exits by cancel (killed), usually it is caused by the limit of results
+ if IsErrorExitCode(err, -1) && stderr.Len() == 0 {
+ return results, nil
+ }
+ // git grep exits with 1 if no results are found
+ if IsErrorExitCode(err, 1) && stderr.Len() == 0 {
+ return nil, nil
+ }
+ if err != nil && !errors.Is(err, context.Canceled) {
+ return nil, fmt.Errorf("unable to run git grep: %w, stderr: %s", err, stderr.String())
+ }
+ return results, nil
+}
diff --git a/modules/git/grep_test.go b/modules/git/grep_test.go
new file mode 100644
index 0000000..3ba7a6e
--- /dev/null
+++ b/modules/git/grep_test.go
@@ -0,0 +1,203 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGrepSearch(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "language_stats_repo"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ res, err := GrepSearch(context.Background(), repo, "public", GrepOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1, 3},
+ LineCodes: []string{
+ "public class HelloWorld",
+ " public static void main(String[] args)",
+ },
+ HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}},
+ },
+ {
+ Filename: "main.vendor.java",
+ LineNumbers: []int{1, 3},
+ LineCodes: []string{
+ "public class HelloWorld",
+ " public static void main(String[] args)",
+ },
+ HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1, ContextLineNumber: 2})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1, 2, 3, 4, 5},
+ LineCodes: []string{
+ "public class HelloWorld",
+ "{",
+ " public static void main(String[] args)",
+ " {",
+ " System.out.println(\"Hello world!\");",
+ },
+ HighlightedRanges: [][3]int{{2, 15, 19}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "world", GrepOptions{MatchesPerFile: 1})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "i-am-a-python.p",
+ LineNumbers: []int{1},
+ LineCodes: []string{"## This is a simple file to do a hello world"},
+ HighlightedRanges: [][3]int{{0, 39, 44}},
+ },
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1},
+ LineCodes: []string{"public class HelloWorld"},
+ HighlightedRanges: [][3]int{{0, 18, 23}},
+ },
+ {
+ Filename: "main.vendor.java",
+ LineNumbers: []int{1},
+ LineCodes: []string{"public class HelloWorld"},
+ HighlightedRanges: [][3]int{{0, 18, 23}},
+ },
+ {
+ Filename: "python-hello/hello.py",
+ LineNumbers: []int{1},
+ LineCodes: []string{"## This is a simple file to do a hello world"},
+ HighlightedRanges: [][3]int{{0, 39, 44}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "no-such-content", GrepOptions{})
+ require.NoError(t, err)
+ assert.Empty(t, res)
+
+ res, err = GrepSearch(context.Background(), &Repository{Path: "no-such-git-repo"}, "no-such-content", GrepOptions{})
+ require.Error(t, err)
+ assert.Empty(t, res)
+}
+
+func TestGrepDashesAreFine(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "with-dashes"), []byte("--"), 0o666))
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "without-dashes"), []byte(".."), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Dashes are cool sometimes"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "--", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "with-dashes", res[0].Filename)
+}
+
+func TestGrepNoBinary(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "BINARY"), []byte("I AM BINARY\n\x00\nYOU WON'T SEE ME"), 0o666))
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "TEXT"), []byte("I AM NOT BINARY\nYOU WILL SEE ME"), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Binary and text files"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "BINARY", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "TEXT", res[0].Filename)
+}
+
+func TestGrepLongFiles(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), bytes.Repeat([]byte{'a'}, 65*1024), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Long file"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Len(t, res[0].LineCodes[0], 65*1024)
+}
+
+func TestGrepRefs(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A'}, 0o666))
+ require.NoError(t, AddChanges(tmpDir, true))
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add A"})
+ require.NoError(t, err)
+
+ require.NoError(t, gitRepo.CreateTag("v1", "HEAD"))
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A', 'B', 'C', 'D'}, 0o666))
+ require.NoError(t, AddChanges(tmpDir, true))
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add BCD"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{RefName: "v1"})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "A", res[0].LineCodes[0])
+}
diff --git a/modules/git/hook.go b/modules/git/hook.go
new file mode 100644
index 0000000..46f93ce
--- /dev/null
+++ b/modules/git/hook.go
@@ -0,0 +1,143 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "errors"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// hookNames is a list of Git server hooks' name that are supported.
+var hookNames = []string{
+ "pre-receive",
+ "update",
+ "post-receive",
+}
+
+// ErrNotValidHook error when a git hook is not valid
+var ErrNotValidHook = errors.New("not a valid Git hook")
+
+// IsValidHookName returns true if given name is a valid Git hook.
+func IsValidHookName(name string) bool {
+ for _, hn := range hookNames {
+ if hn == name {
+ return true
+ }
+ }
+ return false
+}
+
+// Hook represents a Git hook.
+type Hook struct {
+ name string
+ IsActive bool // Indicates whether repository has this hook.
+ Content string // Content of hook if it's active.
+ Sample string // Sample content from Git.
+ path string // Hook file path.
+}
+
+// GetHook returns a Git hook by given name and repository.
+func GetHook(repoPath, name string) (*Hook, error) {
+ if !IsValidHookName(name) {
+ return nil, ErrNotValidHook
+ }
+ h := &Hook{
+ name: name,
+ path: path.Join(repoPath, "hooks", name+".d", name),
+ }
+ samplePath := filepath.Join(repoPath, "hooks", name+".sample")
+ if isFile(h.path) {
+ data, err := os.ReadFile(h.path)
+ if err != nil {
+ return nil, err
+ }
+ h.IsActive = true
+ h.Content = string(data)
+ } else if isFile(samplePath) {
+ data, err := os.ReadFile(samplePath)
+ if err != nil {
+ return nil, err
+ }
+ h.Sample = string(data)
+ }
+ return h, nil
+}
+
+// Name return the name of the hook
+func (h *Hook) Name() string {
+ return h.name
+}
+
+// Update updates hook settings.
+func (h *Hook) Update() error {
+ if len(strings.TrimSpace(h.Content)) == 0 {
+ if isExist(h.path) {
+ err := util.Remove(h.path)
+ if err != nil {
+ return err
+ }
+ }
+ h.IsActive = false
+ return nil
+ }
+ d := filepath.Dir(h.path)
+ if err := os.MkdirAll(d, os.ModePerm); err != nil {
+ return err
+ }
+
+ err := os.WriteFile(h.path, []byte(strings.ReplaceAll(h.Content, "\r", "")), os.ModePerm)
+ if err != nil {
+ return err
+ }
+ h.IsActive = true
+ return nil
+}
+
+// ListHooks returns a list of Git hooks of given repository.
+func ListHooks(repoPath string) (_ []*Hook, err error) {
+ if !isDir(path.Join(repoPath, "hooks")) {
+ return nil, errors.New("hooks path does not exist")
+ }
+
+ hooks := make([]*Hook, len(hookNames))
+ for i, name := range hookNames {
+ hooks[i], err = GetHook(repoPath, name)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return hooks, nil
+}
+
+const (
+ // HookPathUpdate hook update path
+ HookPathUpdate = "hooks/update"
+)
+
+// SetUpdateHook writes given content to update hook of the repository.
+func SetUpdateHook(repoPath, content string) (err error) {
+ log.Debug("Setting update hook: %s", repoPath)
+ hookPath := path.Join(repoPath, HookPathUpdate)
+ isExist, err := util.IsExist(hookPath)
+ if err != nil {
+ log.Debug("Unable to check if %s exists. Error: %v", hookPath, err)
+ return err
+ }
+ if isExist {
+ err = util.Remove(hookPath)
+ } else {
+ err = os.MkdirAll(path.Dir(hookPath), os.ModePerm)
+ }
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(hookPath, []byte(content), 0o777)
+}
diff --git a/modules/git/internal/cmdarg.go b/modules/git/internal/cmdarg.go
new file mode 100644
index 0000000..f8f3c20
--- /dev/null
+++ b/modules/git/internal/cmdarg.go
@@ -0,0 +1,9 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+// CmdArg represents a command argument for git command, and it will be used for the git command directly without any further processing.
+// In most cases, you should use the "AddXxx" functions to add arguments, but not use this type directly.
+// Casting a risky (user-provided) string to CmdArg would cause security issues if it's injected with a "--xxx" argument.
+type CmdArg string
diff --git a/modules/git/last_commit_cache.go b/modules/git/last_commit_cache.go
new file mode 100644
index 0000000..8c7ee5a
--- /dev/null
+++ b/modules/git/last_commit_cache.go
@@ -0,0 +1,159 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Cache represents a caching interface
+type Cache interface {
+ // Put puts value into cache with key and expire time.
+ Put(key string, val any, timeout int64) error
+ // Get gets cached value by given key.
+ Get(key string) any
+}
+
+func getCacheKey(repoPath, commitID, entryPath string) string {
+ hashBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", repoPath, commitID, entryPath)))
+ return fmt.Sprintf("last_commit:%x", hashBytes)
+}
+
+// LastCommitCache represents a cache to store last commit
+type LastCommitCache struct {
+ repoPath string
+ ttl func() int64
+ repo *Repository
+ commitCache map[string]*Commit
+ cache Cache
+}
+
+// NewLastCommitCache creates a new last commit cache for repo
+func NewLastCommitCache(count int64, repoPath string, gitRepo *Repository, cache Cache) *LastCommitCache {
+ if cache == nil {
+ return nil
+ }
+ if count < setting.CacheService.LastCommit.CommitsCount {
+ return nil
+ }
+
+ return &LastCommitCache{
+ repoPath: repoPath,
+ repo: gitRepo,
+ ttl: setting.LastCommitCacheTTLSeconds,
+ cache: cache,
+ }
+}
+
+// Put put the last commit id with commit and entry path
+func (c *LastCommitCache) Put(ref, entryPath, commitID string) error {
+ if c == nil || c.cache == nil {
+ return nil
+ }
+ log.Debug("LastCommitCache save: [%s:%s:%s]", ref, entryPath, commitID)
+ return c.cache.Put(getCacheKey(c.repoPath, ref, entryPath), commitID, c.ttl())
+}
+
+// Get gets the last commit information by commit id and entry path
+func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) {
+ if c == nil || c.cache == nil {
+ return nil, nil
+ }
+
+ commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath)).(string)
+ if !ok || commitID == "" {
+ return nil, nil
+ }
+
+ log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID)
+ if c.commitCache != nil {
+ if commit, ok := c.commitCache[commitID]; ok {
+ log.Debug("LastCommitCache hit level 2: [%s:%s:%s]", ref, entryPath, commitID)
+ return commit, nil
+ }
+ }
+
+ commit, err := c.repo.GetCommit(commitID)
+ if err != nil {
+ return nil, err
+ }
+ if c.commitCache == nil {
+ c.commitCache = make(map[string]*Commit)
+ }
+ c.commitCache[commitID] = commit
+ return commit, nil
+}
+
+// GetCommitByPath gets the last commit for the entry in the provided commit
+func (c *LastCommitCache) GetCommitByPath(commitID, entryPath string) (*Commit, error) {
+ sha, err := NewIDFromString(commitID)
+ if err != nil {
+ return nil, err
+ }
+
+ lastCommit, err := c.Get(sha.String(), entryPath)
+ if err != nil || lastCommit != nil {
+ return lastCommit, err
+ }
+
+ lastCommit, err = c.repo.getCommitByPathWithID(sha, entryPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := c.Put(commitID, entryPath, lastCommit.ID.String()); err != nil {
+ log.Error("Unable to cache %s as the last commit for %q in %s %s. Error %v", lastCommit.ID.String(), entryPath, commitID, c.repoPath, err)
+ }
+
+ return lastCommit, nil
+}
+
+// CacheCommit will cache the commit from the gitRepository
+func (c *Commit) CacheCommit(ctx context.Context) error {
+ if c.repo.LastCommitCache == nil {
+ return nil
+ }
+ return c.recursiveCache(ctx, &c.Tree, "", 1)
+}
+
+func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string, level int) error {
+ if level == 0 {
+ return nil
+ }
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return err
+ }
+
+ entryPaths := make([]string, len(entries))
+ for i, entry := range entries {
+ entryPaths[i] = entry.Name()
+ }
+
+ _, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
+ if err != nil {
+ return err
+ }
+
+ for _, treeEntry := range entries {
+ // entryMap won't contain "" therefore skip this.
+ if treeEntry.IsDir() {
+ subTree, err := tree.SubTree(treeEntry.Name())
+ if err != nil {
+ return err
+ }
+ if err := c.recursiveCache(ctx, subTree, treeEntry.Name(), level-1); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/modules/git/log_name_status.go b/modules/git/log_name_status.go
new file mode 100644
index 0000000..1fd58ab
--- /dev/null
+++ b/modules/git/log_name_status.go
@@ -0,0 +1,437 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "path"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/container"
+
+ "github.com/djherbis/buffer"
+ "github.com/djherbis/nio/v3"
+)
+
+// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
+ // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ stdoutReader, stdoutWriter := nio.Pipe(buffer.New(32 * 1024))
+
+ // Lets also create a context so that we can absolutely ensure that the command should die when we're done
+ ctx, ctxCancel := context.WithCancel(ctx)
+
+ cancel := func() {
+ ctxCancel()
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }
+
+ cmd := NewCommand(ctx)
+ cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
+
+ var files []string
+ if len(paths) < 70 {
+ if treepath != "" {
+ files = append(files, treepath)
+ for _, pth := range paths {
+ if pth != "" {
+ files = append(files, path.Join(treepath, pth))
+ }
+ }
+ } else {
+ for _, pth := range paths {
+ if pth != "" {
+ files = append(files, pth)
+ }
+ }
+ }
+ } else if treepath != "" {
+ files = append(files, treepath)
+ }
+ // Use the :(literal) pathspec magic to handle edge cases with files named like ":file.txt" or "*.jpg"
+ for i, file := range files {
+ files[i] = ":(literal)" + file
+ }
+ cmd.AddDashesAndList(files...)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := cmd.Run(&RunOpts{
+ Dir: repository,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ return
+ }
+
+ _ = stdoutWriter.Close()
+ }()
+
+ // For simplicities sake we'll us a buffered reader to read from the cat-file --batch
+ bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
+
+ return bufReader, cancel
+}
+
+// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
+type LogNameStatusRepoParser struct {
+ treepath string
+ paths []string
+ next []byte
+ buffull bool
+ rd *bufio.Reader
+ cancel func()
+}
+
+// NewLogNameStatusRepoParser returns a new parser for a git log raw output
+func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
+ rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
+ return &LogNameStatusRepoParser{
+ treepath: treepath,
+ paths: paths,
+ rd: rd,
+ cancel: cancel,
+ }
+}
+
+// LogNameStatusCommitData represents a commit artefact from git log raw
+type LogNameStatusCommitData struct {
+ CommitID string
+ ParentIDs []string
+ Paths []bool
+}
+
+// Next returns the next LogStatusCommitData
+func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
+ var err error
+ if len(g.next) == 0 {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+ }
+
+ ret := LogNameStatusCommitData{}
+ if bytes.Equal(g.next, []byte("commit\000")) {
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+ }
+
+ // Our "line" must look like: <commitid> SP (<parent> SP) * NUL
+ commitIDs := string(g.next)
+ if g.buffull {
+ more, err := g.rd.ReadString('\x00')
+ if err != nil {
+ return nil, err
+ }
+ commitIDs += more
+ }
+ commitIDs = commitIDs[:len(commitIDs)-1]
+ splitIDs := strings.Split(commitIDs, " ")
+ ret.CommitID = splitIDs[0]
+ if len(splitIDs) > 1 {
+ ret.ParentIDs = splitIDs[1:]
+ }
+
+ // now read the next "line"
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+
+ if err == io.EOF || !(g.next[0] == '\n' || g.next[0] == '\000') {
+ return &ret, nil
+ }
+
+ // Ok we have some changes.
+ // This line will look like: NL <fname> NUL
+ //
+ // Subsequent lines will not have the NL - so drop it here - g.bufffull must also be false at this point too.
+ if g.next[0] == '\n' {
+ g.next = g.next[1:]
+ } else {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+ if len(g.next) == 0 {
+ return &ret, nil
+ }
+ if g.next[0] == '\x00' {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+ }
+ }
+
+ fnameBuf := make([]byte, 4096)
+
+diffloop:
+ for {
+ if err == io.EOF || bytes.Equal(g.next, []byte("commit\000")) {
+ return &ret, nil
+ }
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return &ret, nil
+ } else {
+ return nil, err
+ }
+ }
+ copy(fnameBuf, g.next)
+ if len(fnameBuf) < len(g.next) {
+ fnameBuf = append(fnameBuf, g.next[len(fnameBuf):]...)
+ } else {
+ fnameBuf = fnameBuf[:len(g.next)]
+ }
+ if err != nil {
+ if err != bufio.ErrBufferFull {
+ return nil, err
+ }
+ more, err := g.rd.ReadBytes('\x00')
+ if err != nil {
+ return nil, err
+ }
+ fnameBuf = append(fnameBuf, more...)
+ }
+
+ // read the next line
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+
+ if treepath != "" {
+ if !bytes.HasPrefix(fnameBuf, []byte(treepath)) {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ }
+ fnameBuf = fnameBuf[len(treepath) : len(fnameBuf)-1]
+ if len(fnameBuf) > maxpathlen {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ if len(fnameBuf) > 0 {
+ if len(treepath) > 0 {
+ if fnameBuf[0] != '/' || bytes.IndexByte(fnameBuf[1:], '/') >= 0 {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ fnameBuf = fnameBuf[1:]
+ } else if bytes.IndexByte(fnameBuf, '/') >= 0 {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ }
+
+ idx, ok := paths2ids[string(fnameBuf)]
+ if !ok {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ if ret.Paths == nil {
+ ret.Paths = changed
+ }
+ changed[idx] = true
+ }
+}
+
+// Close closes the parser
+func (g *LogNameStatusRepoParser) Close() {
+ g.cancel()
+}
+
+// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
+func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
+ headRef := head.ID.String()
+
+ tree, err := head.SubTree(treepath)
+ if err != nil {
+ return nil, err
+ }
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(paths) == 0 {
+ paths = make([]string, 0, len(entries)+1)
+ paths = append(paths, "")
+ for _, entry := range entries {
+ paths = append(paths, entry.Name())
+ }
+ } else {
+ sort.Strings(paths)
+ if paths[0] != "" {
+ paths = append([]string{""}, paths...)
+ }
+ // remove duplicates
+ for i := len(paths) - 1; i > 0; i-- {
+ if paths[i] == paths[i-1] {
+ paths = append(paths[:i-1], paths[i:]...)
+ }
+ }
+ }
+
+ path2idx := map[string]int{}
+ maxpathlen := len(treepath)
+
+ for i := range paths {
+ path2idx[paths[i]] = i
+ pthlen := len(paths[i]) + len(treepath) + 1
+ if pthlen > maxpathlen {
+ maxpathlen = pthlen
+ }
+ }
+
+ g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
+ // don't use defer g.Close() here as g may change its value - instead wrap in a func
+ defer func() {
+ g.Close()
+ }()
+
+ results := make([]string, len(paths))
+ remaining := len(paths)
+ nextRestart := (len(paths) * 3) / 4
+ if nextRestart > 70 {
+ nextRestart = 70
+ }
+ lastEmptyParent := head.ID.String()
+ commitSinceLastEmptyParent := uint64(0)
+ commitSinceNextRestart := uint64(0)
+ parentRemaining := make(container.Set[string])
+
+ changed := make([]bool, len(paths))
+
+heaploop:
+ for {
+ select {
+ case <-ctx.Done():
+ if ctx.Err() == context.DeadlineExceeded {
+ break heaploop
+ }
+ g.Close()
+ return nil, ctx.Err()
+ default:
+ }
+ current, err := g.Next(treepath, path2idx, changed, maxpathlen)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ break heaploop
+ }
+ g.Close()
+ return nil, err
+ }
+ if current == nil {
+ break heaploop
+ }
+ parentRemaining.Remove(current.CommitID)
+ for i, found := range current.Paths {
+ if !found {
+ continue
+ }
+ changed[i] = false
+ if results[i] == "" {
+ results[i] = current.CommitID
+ if err := repo.LastCommitCache.Put(headRef, path.Join(treepath, paths[i]), current.CommitID); err != nil {
+ return nil, err
+ }
+ delete(path2idx, paths[i])
+ remaining--
+ if results[0] == "" {
+ results[0] = current.CommitID
+ if err := repo.LastCommitCache.Put(headRef, treepath, current.CommitID); err != nil {
+ return nil, err
+ }
+ delete(path2idx, "")
+ remaining--
+ }
+ }
+ }
+
+ if remaining <= 0 {
+ break heaploop
+ }
+ commitSinceLastEmptyParent++
+ if len(parentRemaining) == 0 {
+ lastEmptyParent = current.CommitID
+ commitSinceLastEmptyParent = 0
+ }
+ if remaining <= nextRestart {
+ commitSinceNextRestart++
+ if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
+ g.Close()
+ remainingPaths := make([]string, 0, len(paths))
+ for i, pth := range paths {
+ if results[i] == "" {
+ remainingPaths = append(remainingPaths, pth)
+ }
+ }
+ g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
+ parentRemaining = make(container.Set[string])
+ nextRestart = (remaining * 3) / 4
+ continue heaploop
+ }
+ }
+ parentRemaining.AddMultiple(current.ParentIDs...)
+ }
+ g.Close()
+
+ resultsMap := map[string]string{}
+ for i, pth := range paths {
+ resultsMap[pth] = results[i]
+ }
+
+ return resultsMap, nil
+}
diff --git a/modules/git/notes.go b/modules/git/notes.go
new file mode 100644
index 0000000..ee628c0
--- /dev/null
+++ b/modules/git/notes.go
@@ -0,0 +1,99 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// NotesRef is the git ref where Gitea will look for git-notes data.
+// The value ("refs/notes/commits") is the default ref used by git-notes.
+const NotesRef = "refs/notes/commits"
+
+// Note stores information about a note created using git-notes.
+type Note struct {
+ Message []byte
+ Commit *Commit
+}
+
+// GetNote retrieves the git-notes data for a given commit.
+// FIXME: Add LastCommitCache support
+func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) error {
+ log.Trace("Searching for git note corresponding to the commit %q in the repository %q", commitID, repo.Path)
+ notes, err := repo.GetCommit(NotesRef)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return err
+ }
+ log.Error("Unable to get commit from ref %q. Error: %v", NotesRef, err)
+ return err
+ }
+
+ path := ""
+
+ tree := &notes.Tree
+ log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", tree.ID, commitID)
+
+ var entry *TreeEntry
+ originalCommitID := commitID
+ for len(commitID) > 2 {
+ entry, err = tree.GetTreeEntryByPath(commitID)
+ if err == nil {
+ path += commitID
+ break
+ }
+ if IsErrNotExist(err) {
+ tree, err = tree.SubTree(commitID[0:2])
+ path += commitID[0:2] + "/"
+ commitID = commitID[2:]
+ }
+ if err != nil {
+ // Err may have been updated by the SubTree we need to recheck if it's again an ErrNotExist
+ if !IsErrNotExist(err) {
+ log.Error("Unable to find git note corresponding to the commit %q. Error: %v", originalCommitID, err)
+ }
+ return err
+ }
+ }
+
+ blob := entry.Blob()
+ dataRc, err := blob.DataAsync()
+ if err != nil {
+ log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
+ return err
+ }
+ closed := false
+ defer func() {
+ if !closed {
+ _ = dataRc.Close()
+ }
+ }()
+ d, err := io.ReadAll(dataRc)
+ if err != nil {
+ log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
+ return err
+ }
+ _ = dataRc.Close()
+ closed = true
+ note.Message = d
+
+ treePath := ""
+ if idx := strings.LastIndex(path, "/"); idx > -1 {
+ treePath = path[:idx]
+ path = path[idx+1:]
+ }
+
+ lastCommits, err := GetLastCommitForPaths(ctx, notes, treePath, []string{path})
+ if err != nil {
+ log.Error("Unable to get the commit for the path %q. Error: %v", treePath, err)
+ return err
+ }
+ note.Commit = lastCommits[path]
+
+ return nil
+}
diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go
new file mode 100644
index 0000000..bbb16cc
--- /dev/null
+++ b/modules/git/notes_test.go
@@ -0,0 +1,53 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetNotes(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note contents\n"), note.Message)
+ assert.Equal(t, "Vladimir Panteleev", note.Commit.Author.Name)
+}
+
+func TestGetNestedNotes(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo3_notes")
+ repo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note 2"), note.Message)
+ err = GetNote(context.Background(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note 1"), note.Message)
+}
+
+func TestGetNonExistentNotes(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), bareRepo1, "non_existent_sha", &note)
+ require.Error(t, err)
+ assert.IsType(t, ErrNotExist{}, err)
+}
diff --git a/modules/git/object_format.go b/modules/git/object_format.go
new file mode 100644
index 0000000..db9120d
--- /dev/null
+++ b/modules/git/object_format.go
@@ -0,0 +1,143 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "crypto/sha1"
+ "crypto/sha256"
+ "hash"
+ "regexp"
+ "strconv"
+)
+
+// sha1Pattern can be used to determine if a string is an valid sha
+var sha1Pattern = regexp.MustCompile(`^[0-9a-f]{4,40}$`)
+
+// sha256Pattern can be used to determine if a string is an valid sha
+var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`)
+
+type ObjectFormat interface {
+ // Name returns the name of the object format
+ Name() string
+ // EmptyObjectID creates a new empty ObjectID from an object format hash name
+ EmptyObjectID() ObjectID
+ // EmptyTree is the hash of an empty tree
+ EmptyTree() ObjectID
+ // FullLength is the length of the hash's hex string
+ FullLength() int
+ // IsValid returns true if the input is a valid hash
+ IsValid(input string) bool
+ // MustID creates a new ObjectID from a byte slice
+ MustID(b []byte) ObjectID
+ // ComputeHash compute the hash for a given ObjectType and content
+ ComputeHash(t ObjectType, content []byte) ObjectID
+}
+
+func computeHash(dst []byte, hasher hash.Hash, t ObjectType, content []byte) {
+ _, _ = hasher.Write(t.Bytes())
+ _, _ = hasher.Write([]byte(" "))
+ _, _ = hasher.Write([]byte(strconv.Itoa(len(content))))
+ _, _ = hasher.Write([]byte{0})
+ _, _ = hasher.Write(content)
+ hasher.Sum(dst)
+}
+
+/* SHA1 Type */
+type Sha1ObjectFormatImpl struct{}
+
+var (
+ emptySha1ObjectID = &Sha1Hash{}
+ emptySha1Tree = &Sha1Hash{
+ 0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60,
+ 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04,
+ }
+)
+
+func (Sha1ObjectFormatImpl) Name() string { return "sha1" }
+func (Sha1ObjectFormatImpl) EmptyObjectID() ObjectID {
+ return emptySha1ObjectID
+}
+
+func (Sha1ObjectFormatImpl) EmptyTree() ObjectID {
+ return emptySha1Tree
+}
+func (Sha1ObjectFormatImpl) FullLength() int { return 40 }
+func (Sha1ObjectFormatImpl) IsValid(input string) bool {
+ return sha1Pattern.MatchString(input)
+}
+
+func (Sha1ObjectFormatImpl) MustID(b []byte) ObjectID {
+ var id Sha1Hash
+ copy(id[0:20], b)
+ return &id
+}
+
+// ComputeHash compute the hash for a given ObjectType and content
+func (h Sha1ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
+ var obj Sha1Hash
+ computeHash(obj[:0], sha1.New(), t, content)
+ return &obj
+}
+
+/* SHA256 Type */
+type Sha256ObjectFormatImpl struct{}
+
+var (
+ emptySha256ObjectID = &Sha256Hash{}
+ emptySha256Tree = &Sha256Hash{
+ 0x6e, 0xf1, 0x9b, 0x41, 0x22, 0x5c, 0x53, 0x69, 0xf1, 0xc1,
+ 0x04, 0xd4, 0x5d, 0x8d, 0x85, 0xef, 0xa9, 0xb0, 0x57, 0xb5,
+ 0x3b, 0x14, 0xb4, 0xb9, 0xb9, 0x39, 0xdd, 0x74, 0xde, 0xcc,
+ 0x53, 0x21,
+ }
+)
+
+func (Sha256ObjectFormatImpl) Name() string { return "sha256" }
+func (Sha256ObjectFormatImpl) EmptyObjectID() ObjectID {
+ return emptySha256ObjectID
+}
+
+func (Sha256ObjectFormatImpl) EmptyTree() ObjectID {
+ return emptySha256Tree
+}
+func (Sha256ObjectFormatImpl) FullLength() int { return 64 }
+func (Sha256ObjectFormatImpl) IsValid(input string) bool {
+ return sha256Pattern.MatchString(input)
+}
+
+func (Sha256ObjectFormatImpl) MustID(b []byte) ObjectID {
+ var id Sha256Hash
+ copy(id[0:32], b)
+ return &id
+}
+
+// ComputeHash compute the hash for a given ObjectType and content
+func (h Sha256ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
+ var obj Sha256Hash
+ computeHash(obj[:0], sha256.New(), t, content)
+ return &obj
+}
+
+var (
+ Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
+ Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
+ // any addition must be reflected in IsEmptyCommitID
+)
+
+var SupportedObjectFormats = []ObjectFormat{
+ Sha1ObjectFormat,
+}
+
+func ObjectFormatFromName(name string) ObjectFormat {
+ for _, objectFormat := range SupportedObjectFormats {
+ if name == objectFormat.Name() {
+ return objectFormat
+ }
+ }
+ return nil
+}
+
+func IsValidObjectFormat(name string) bool {
+ return ObjectFormatFromName(name) != nil
+}
diff --git a/modules/git/object_id.go b/modules/git/object_id.go
new file mode 100644
index 0000000..ecbc158
--- /dev/null
+++ b/modules/git/object_id.go
@@ -0,0 +1,114 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "encoding/hex"
+ "fmt"
+)
+
+type ObjectID interface {
+ String() string
+ IsZero() bool
+ RawValue() []byte
+ Type() ObjectFormat
+}
+
+/* SHA1 */
+type Sha1Hash [20]byte
+
+func (h *Sha1Hash) String() string {
+ return hex.EncodeToString(h[:])
+}
+
+func (h *Sha1Hash) IsZero() bool {
+ empty := Sha1Hash{}
+ return bytes.Equal(empty[:], h[:])
+}
+func (h *Sha1Hash) RawValue() []byte { return h[:] }
+func (*Sha1Hash) Type() ObjectFormat { return Sha1ObjectFormat }
+
+var _ ObjectID = &Sha1Hash{}
+
+func MustIDFromString(hexHash string) ObjectID {
+ id, err := NewIDFromString(hexHash)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+/* SHA256 */
+type Sha256Hash [32]byte
+
+func (h *Sha256Hash) String() string {
+ return hex.EncodeToString(h[:])
+}
+
+func (h *Sha256Hash) IsZero() bool {
+ empty := Sha256Hash{}
+ return bytes.Equal(empty[:], h[:])
+}
+func (h *Sha256Hash) RawValue() []byte { return h[:] }
+func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat }
+
+/* utility */
+func NewIDFromString(hexHash string) (ObjectID, error) {
+ var theObjectFormat ObjectFormat
+ for _, objectFormat := range SupportedObjectFormats {
+ if len(hexHash) == objectFormat.FullLength() {
+ theObjectFormat = objectFormat
+ break
+ }
+ }
+
+ if theObjectFormat == nil {
+ return nil, fmt.Errorf("length %d has no matched object format: %s", len(hexHash), hexHash)
+ }
+
+ b, err := hex.DecodeString(hexHash)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(b) != theObjectFormat.FullLength()/2 {
+ return theObjectFormat.EmptyObjectID(), fmt.Errorf("length must be %d: %v", theObjectFormat.FullLength(), b)
+ }
+ return theObjectFormat.MustID(b), nil
+}
+
+// IsEmptyCommitID checks if an hexadecimal string represents an empty commit according to git (only '0').
+// If objectFormat is not nil, the length will be checked as well (otherwise the length must match the sha1 or sha256 length).
+func IsEmptyCommitID(commitID string, objectFormat ObjectFormat) bool {
+ if commitID == "" {
+ return true
+ }
+ if objectFormat == nil {
+ if Sha1ObjectFormat.FullLength() != len(commitID) && Sha256ObjectFormat.FullLength() != len(commitID) {
+ return false
+ }
+ } else if objectFormat.FullLength() != len(commitID) {
+ return false
+ }
+ for _, c := range commitID {
+ if c != '0' {
+ return false
+ }
+ }
+ return true
+}
+
+// ComputeBlobHash compute the hash for a given blob content
+func ComputeBlobHash(hashType ObjectFormat, content []byte) ObjectID {
+ return hashType.ComputeHash(ObjectBlob, content)
+}
+
+type ErrInvalidSHA struct {
+ SHA string
+}
+
+func (err ErrInvalidSHA) Error() string {
+ return fmt.Sprintf("invalid sha: %s", err.SHA)
+}
diff --git a/modules/git/object_id_test.go b/modules/git/object_id_test.go
new file mode 100644
index 0000000..00a24e3
--- /dev/null
+++ b/modules/git/object_id_test.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsValidSHAPattern(t *testing.T) {
+ h := Sha1ObjectFormat
+ assert.True(t, h.IsValid("fee1"))
+ assert.True(t, h.IsValid("abc000"))
+ assert.True(t, h.IsValid("9023902390239023902390239023902390239023"))
+ assert.False(t, h.IsValid("90239023902390239023902390239023902390239023"))
+ assert.False(t, h.IsValid("abc"))
+ assert.False(t, h.IsValid("123g"))
+ assert.False(t, h.IsValid("some random text"))
+
+ assert.Equal(t, "79ee38a6416c1ede423ec7ee0a8639ceea4aad22", ComputeBlobHash(Sha1ObjectFormat, []byte("some random blob")).String())
+ assert.Equal(t, "d5c6407415d85df49592672aa421aed39b9db5e3", ComputeBlobHash(Sha1ObjectFormat, []byte("same length blob")).String())
+ assert.Equal(t, "df0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", ComputeBlobHash(Sha256ObjectFormat, []byte("some random blob")).String())
+}
+
+func TestIsEmptyCommitID(t *testing.T) {
+ assert.True(t, IsEmptyCommitID("", nil))
+ assert.True(t, IsEmptyCommitID("", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("79ee38a6416c1ede423ec7ee0a8639ceea4aad20", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", nil))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha1ObjectFormat))
+ assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("00000000000000000000000000000000000000000", nil))
+
+ assert.False(t, IsEmptyCommitID("0f0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", nil))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", nil))
+ assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("1", nil))
+ assert.False(t, IsEmptyCommitID("0", nil))
+
+ assert.False(t, IsEmptyCommitID("010", nil))
+ assert.False(t, IsEmptyCommitID("0 0", nil))
+}
diff --git a/modules/git/object_signature.go b/modules/git/object_signature.go
new file mode 100644
index 0000000..35fa671
--- /dev/null
+++ b/modules/git/object_signature.go
@@ -0,0 +1,11 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+// ObjectSignature represents a git object (commit, tag) signature part.
+type ObjectSignature struct {
+ Signature string
+ Payload string // TODO check if can be reconstruct from the rest of commit information to not have duplicate data
+}
diff --git a/modules/git/parse.go b/modules/git/parse.go
new file mode 100644
index 0000000..8c2c411
--- /dev/null
+++ b/modules/git/parse.go
@@ -0,0 +1,137 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// ParseTreeEntries parses the output of a `git ls-tree -l` command.
+func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
+ return parseTreeEntries(data, nil)
+}
+
+var sepSpace = []byte{' '}
+
+func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
+ var err error
+ entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
+ for pos := 0; pos < len(data); {
+ // expect line to be of the form:
+ // <mode> <type> <sha> <space-padded-size>\t<filename>
+ // <mode> <type> <sha>\t<filename>
+ posEnd := bytes.IndexByte(data[pos:], '\n')
+ if posEnd == -1 {
+ posEnd = len(data)
+ } else {
+ posEnd += pos
+ }
+ line := data[pos:posEnd]
+ posTab := bytes.IndexByte(line, '\t')
+ if posTab == -1 {
+ return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line)
+ }
+
+ entry := new(TreeEntry)
+ entry.ptree = ptree
+
+ entryAttrs := line[:posTab]
+ entryName := line[posTab+1:]
+
+ entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
+ _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type
+ entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
+ if len(entryAttrs) > 0 {
+ entrySize := entryAttrs // the last field is the space-padded-size
+ entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64)
+ entry.sized = true
+ }
+
+ switch string(entryMode) {
+ case "100644":
+ entry.entryMode = EntryModeBlob
+ case "100755":
+ entry.entryMode = EntryModeExec
+ case "120000":
+ entry.entryMode = EntryModeSymlink
+ case "160000":
+ entry.entryMode = EntryModeCommit
+ case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
+ entry.entryMode = EntryModeTree
+ default:
+ return nil, fmt.Errorf("unknown type: %v", string(entryMode))
+ }
+
+ entry.ID, err = NewIDFromString(string(entryObjectID))
+ if err != nil {
+ return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
+ }
+
+ if len(entryName) > 0 && entryName[0] == '"' {
+ entry.name, err = strconv.Unquote(string(entryName))
+ if err != nil {
+ return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
+ }
+ } else {
+ entry.name = string(entryName)
+ }
+
+ pos = posEnd + 1
+ entries = append(entries, entry)
+ }
+ return entries, nil
+}
+
+func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) {
+ fnameBuf := make([]byte, 4096)
+ modeBuf := make([]byte, 40)
+ shaBuf := make([]byte, objectFormat.FullLength())
+ entries := make([]*TreeEntry, 0, 10)
+
+loop:
+ for sz > 0 {
+ mode, fname, sha, count, err := ParseTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf)
+ if err != nil {
+ if err == io.EOF {
+ break loop
+ }
+ return nil, err
+ }
+ sz -= int64(count)
+ entry := new(TreeEntry)
+ entry.ptree = ptree
+
+ switch string(mode) {
+ case "100644":
+ entry.entryMode = EntryModeBlob
+ case "100755":
+ entry.entryMode = EntryModeExec
+ case "120000":
+ entry.entryMode = EntryModeSymlink
+ case "160000":
+ entry.entryMode = EntryModeCommit
+ case "40000", "40755": // git uses 40000 for tree object, but some users may get 40755 for unknown reasons
+ entry.entryMode = EntryModeTree
+ default:
+ log.Debug("Unknown mode: %v", string(mode))
+ return nil, fmt.Errorf("unknown mode: %v", string(mode))
+ }
+
+ entry.ID = objectFormat.MustID(sha)
+ entry.name = string(fname)
+ entries = append(entries, entry)
+ }
+ if _, err := rd.Discard(1); err != nil {
+ return entries, err
+ }
+
+ return entries, nil
+}
diff --git a/modules/git/parse_test.go b/modules/git/parse_test.go
new file mode 100644
index 0000000..89c6e03
--- /dev/null
+++ b/modules/git/parse_test.go
@@ -0,0 +1,103 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseTreeEntriesLong(t *testing.T) {
+ testCases := []struct {
+ Input string
+ Expected []*TreeEntry
+ }{
+ {
+ Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af 8218 README.md
+100644 blob 037f27dc9d353ae4fd50f0474b2194c593914e35 4681 README_ZH.md
+100644 blob 9846a94f7e8350a916632929d0fda38c90dd2ca8 429 SECURITY.md
+040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d - assets
+`,
+ Expected: []*TreeEntry{
+ {
+ ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
+ name: "README.md",
+ entryMode: EntryModeBlob,
+ size: 8218,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("037f27dc9d353ae4fd50f0474b2194c593914e35"),
+ name: "README_ZH.md",
+ entryMode: EntryModeBlob,
+ size: 4681,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("9846a94f7e8350a916632929d0fda38c90dd2ca8"),
+ name: "SECURITY.md",
+ entryMode: EntryModeBlob,
+ size: 429,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
+ name: "assets",
+ entryMode: EntryModeTree,
+ sized: true,
+ },
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ entries, err := ParseTreeEntries([]byte(testCase.Input))
+ require.NoError(t, err)
+ assert.Len(t, entries, len(testCase.Expected))
+ for i, entry := range entries {
+ assert.EqualValues(t, testCase.Expected[i], entry)
+ }
+ }
+}
+
+func TestParseTreeEntriesShort(t *testing.T) {
+ testCases := []struct {
+ Input string
+ Expected []*TreeEntry
+ }{
+ {
+ Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af README.md
+040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d assets
+`,
+ Expected: []*TreeEntry{
+ {
+ ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
+ name: "README.md",
+ entryMode: EntryModeBlob,
+ },
+ {
+ ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
+ name: "assets",
+ entryMode: EntryModeTree,
+ },
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ entries, err := ParseTreeEntries([]byte(testCase.Input))
+ require.NoError(t, err)
+ assert.Len(t, entries, len(testCase.Expected))
+ for i, entry := range entries {
+ assert.EqualValues(t, testCase.Expected[i], entry)
+ }
+ }
+}
+
+func TestParseTreeEntriesInvalid(t *testing.T) {
+ // there was a panic: "runtime error: slice bounds out of range" when the input was invalid: #20315
+ entries, err := ParseTreeEntries([]byte("100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af"))
+ require.Error(t, err)
+ assert.Empty(t, entries)
+}
diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go
new file mode 100644
index 0000000..4677218
--- /dev/null
+++ b/modules/git/pipeline/catfile.go
@@ -0,0 +1,108 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// CatFileBatchCheck runs cat-file with --batch-check
+func CatFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToCheckReader.Close()
+ defer catFileCheckWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "cat-file", "--batch-check")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdin: shasToCheckReader,
+ Stdout: catFileCheckWriter,
+ Stderr: stderr,
+ }); err != nil {
+ _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
+
+// CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all
+func CatFileBatchCheckAllObjects(ctx context.Context, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) {
+ defer wg.Done()
+ defer catFileCheckWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "cat-file", "--batch-check", "--batch-all-objects")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: catFileCheckWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String())
+ err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %w - %s", tmpBasePath, err, errbuf.String())
+ _ = catFileCheckWriter.CloseWithError(err)
+ errChan <- err
+ }
+}
+
+// CatFileBatch runs cat-file --batch
+func CatFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToBatchReader.Close()
+ defer catFileBatchWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ if err := git.NewCommand(ctx, "cat-file", "--batch").Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: catFileBatchWriter,
+ Stdin: shasToBatchReader,
+ Stderr: stderr,
+ }); err != nil {
+ _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
+
+// BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size
+func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) {
+ defer wg.Done()
+ defer catFileCheckReader.Close()
+ scanner := bufio.NewScanner(catFileCheckReader)
+ defer func() {
+ _ = shasToBatchWriter.CloseWithError(scanner.Err())
+ }()
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ fields := strings.Split(line, " ")
+ if len(fields) < 3 || fields[1] != "blob" {
+ continue
+ }
+ size, _ := strconv.Atoi(fields[2])
+ if size > 1024 {
+ continue
+ }
+ toWrite := []byte(fields[0] + "\n")
+ for len(toWrite) > 0 {
+ n, err := shasToBatchWriter.Write(toWrite)
+ if err != nil {
+ _ = catFileCheckReader.CloseWithError(err)
+ break
+ }
+ toWrite = toWrite[n:]
+ }
+ }
+}
diff --git a/modules/git/pipeline/lfs.go b/modules/git/pipeline/lfs.go
new file mode 100644
index 0000000..3407eb9
--- /dev/null
+++ b/modules/git/pipeline/lfs.go
@@ -0,0 +1,254 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// LFSResult represents commits found using a provided pointer file hash
+type LFSResult struct {
+ Name string
+ SHA string
+ Summary string
+ When time.Time
+ ParentHashes []git.ObjectID
+ BranchName string
+ FullCommitName string
+}
+
+type lfsResultSlice []*LFSResult
+
+func (a lfsResultSlice) Len() int { return len(a) }
+func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
+func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) }
+
+func lfsError(msg string, err error) error {
+ return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err)
+}
+
+// FindLFSFile finds commits that contain a provided pointer file hash
+func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) {
+ resultsMap := map[string]*LFSResult{}
+ results := make([]*LFSResult, 0)
+
+ basePath := repo.Path
+
+ // Use rev-list to provide us with all commits in order
+ revListReader, revListWriter := io.Pipe()
+ defer func() {
+ _ = revListWriter.Close()
+ _ = revListReader.Close()
+ }()
+
+ go func() {
+ stderr := strings.Builder{}
+ err := git.NewCommand(repo.Ctx, "rev-list", "--all").Run(&git.RunOpts{
+ Dir: repo.Path,
+ Stdout: revListWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = revListWriter.CloseWithError(git.ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = revListWriter.Close()
+ }
+ }()
+
+ // Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ // We'll use a scanner for the revList because it's simpler than a bufio.Reader
+ scan := bufio.NewScanner(revListReader)
+ trees := [][]byte{}
+ paths := []string{}
+
+ fnameBuf := make([]byte, 4096)
+ modeBuf := make([]byte, 40)
+ workingShaBuf := make([]byte, objectID.Type().FullLength()/2)
+
+ for scan.Scan() {
+ // Get the next commit ID
+ commitID := scan.Bytes()
+
+ // push the commit to the cat-file --batch process
+ _, err := batchStdinWriter.Write(commitID)
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte{'\n'})
+ if err != nil {
+ return nil, err
+ }
+
+ var curCommit *git.Commit
+ curPath := ""
+
+ commitReadingLoop:
+ for {
+ _, typ, size, err := git.ReadBatchLine(batchReader)
+ if err != nil {
+ return nil, err
+ }
+
+ switch typ {
+ case "tag":
+ // This shouldn't happen but if it does well just get the commit and try again
+ id, err := git.ReadTagObjectID(batchReader, size)
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte(id + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ continue
+ case "commit":
+ // Read in the commit to get its tree and in case this is one of the last used commits
+ curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(string(commitID)), io.LimitReader(batchReader, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+
+ if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil {
+ return nil, err
+ }
+ curPath = ""
+ case "tree":
+ var n int64
+ for n < size {
+ mode, fname, binObjectID, count, err := git.ParseTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf)
+ if err != nil {
+ return nil, err
+ }
+ n += int64(count)
+ if bytes.Equal(binObjectID, objectID.RawValue()) {
+ result := LFSResult{
+ Name: curPath + string(fname),
+ SHA: curCommit.ID.String(),
+ Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0],
+ When: curCommit.Author.When,
+ ParentHashes: curCommit.Parents,
+ }
+ resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result
+ } else if string(mode) == git.EntryModeTree.String() {
+ hexObjectID := make([]byte, objectID.Type().FullLength())
+ git.BinToHex(objectID.Type(), binObjectID, hexObjectID)
+ trees = append(trees, hexObjectID)
+ paths = append(paths, curPath+string(fname)+"/")
+ }
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+ if len(trees) > 0 {
+ _, err := batchStdinWriter.Write(trees[len(trees)-1])
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte("\n"))
+ if err != nil {
+ return nil, err
+ }
+ curPath = paths[len(paths)-1]
+ trees = trees[:len(trees)-1]
+ paths = paths[:len(paths)-1]
+ } else {
+ break commitReadingLoop
+ }
+ default:
+ if err := git.DiscardFull(batchReader, size+1); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+
+ if err := scan.Err(); err != nil {
+ return nil, err
+ }
+
+ for _, result := range resultsMap {
+ hasParent := false
+ for _, parentID := range result.ParentHashes {
+ if _, hasParent = resultsMap[parentID.String()+":"+result.Name]; hasParent {
+ break
+ }
+ }
+ if !hasParent {
+ results = append(results, result)
+ }
+ }
+
+ sort.Sort(lfsResultSlice(results))
+
+ // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
+ shasToNameReader, shasToNameWriter := io.Pipe()
+ nameRevStdinReader, nameRevStdinWriter := io.Pipe()
+ errChan := make(chan error, 1)
+ wg := sync.WaitGroup{}
+ wg.Add(3)
+
+ go func() {
+ defer wg.Done()
+ scanner := bufio.NewScanner(nameRevStdinReader)
+ i := 0
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ result := results[i]
+ result.FullCommitName = line
+ result.BranchName = strings.Split(line, "~")[0]
+ i++
+ }
+ }()
+ go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath)
+ go func() {
+ defer wg.Done()
+ defer shasToNameWriter.Close()
+ for _, result := range results {
+ _, err := shasToNameWriter.Write([]byte(result.SHA))
+ if err != nil {
+ errChan <- err
+ break
+ }
+ _, err = shasToNameWriter.Write([]byte{'\n'})
+ if err != nil {
+ errChan <- err
+ break
+ }
+ }
+ }()
+
+ wg.Wait()
+
+ select {
+ case err, has := <-errChan:
+ if has {
+ return nil, lfsError("unable to obtain name for LFS files", err)
+ }
+ default:
+ }
+
+ return results, nil
+}
diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go
new file mode 100644
index 0000000..ad583a7
--- /dev/null
+++ b/modules/git/pipeline/namerev.go
@@ -0,0 +1,33 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// NameRevStdin runs name-rev --stdin
+func NameRevStdin(ctx context.Context, shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToNameReader.Close()
+ defer nameRevStdinWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ if err := git.NewCommand(ctx, "name-rev", "--stdin", "--name-only", "--always").Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: nameRevStdinWriter,
+ Stdin: shasToNameReader,
+ Stderr: stderr,
+ }); err != nil {
+ _ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go
new file mode 100644
index 0000000..d88ebe7
--- /dev/null
+++ b/modules/git/pipeline/revlist.go
@@ -0,0 +1,86 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter
+func RevListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) {
+ defer wg.Done()
+ defer revListWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "rev-list", "--objects", "--all")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: basePath,
+ Stdout: revListWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String())
+ err = fmt.Errorf("git rev-list --objects --all [%s]: %w - %s", basePath, err, errbuf.String())
+ _ = revListWriter.CloseWithError(err)
+ errChan <- err
+ }
+}
+
+// RevListObjects run rev-list --objects from headSHA to baseSHA
+func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) {
+ defer wg.Done()
+ defer revListWriter.Close()
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "rev-list", "--objects").AddDynamicArguments(headSHA)
+ if baseSHA != "" {
+ cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA)
+ }
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: revListWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String())
+ errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String())
+ }
+}
+
+// BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs
+func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) {
+ defer wg.Done()
+ defer revListReader.Close()
+ scanner := bufio.NewScanner(revListReader)
+ defer func() {
+ _ = shasToCheckWriter.CloseWithError(scanner.Err())
+ }()
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ fields := strings.Split(line, " ")
+ if len(fields) < 2 || len(fields[1]) == 0 {
+ continue
+ }
+ toWrite := []byte(fields[0] + "\n")
+ for len(toWrite) > 0 {
+ n, err := shasToCheckWriter.Write(toWrite)
+ if err != nil {
+ _ = revListReader.CloseWithError(err)
+ break
+ }
+ toWrite = toWrite[n:]
+ }
+ }
+}
diff --git a/modules/git/pushoptions/pushoptions.go b/modules/git/pushoptions/pushoptions.go
new file mode 100644
index 0000000..9709a8b
--- /dev/null
+++ b/modules/git/pushoptions/pushoptions.go
@@ -0,0 +1,113 @@
+// Copyright twenty-panda <twenty-panda@posteo.com>
+// SPDX-License-Identifier: MIT
+
+package pushoptions
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type Key string
+
+const (
+ RepoPrivate = Key("repo.private")
+ RepoTemplate = Key("repo.template")
+ AgitTopic = Key("topic")
+ AgitForcePush = Key("force-push")
+ AgitTitle = Key("title")
+ AgitDescription = Key("description")
+
+ envPrefix = "GIT_PUSH_OPTION"
+ EnvCount = envPrefix + "_COUNT"
+ EnvFormat = envPrefix + "_%d"
+)
+
+type Interface interface {
+ ReadEnv() Interface
+ Parse(string) bool
+ Map() map[string]string
+
+ ChangeRepoSettings() bool
+
+ Empty() bool
+
+ GetBool(key Key, def bool) bool
+ GetString(key Key) (val string, ok bool)
+}
+
+type gitPushOptions map[string]string
+
+func New() Interface {
+ pushOptions := gitPushOptions(make(map[string]string))
+ return &pushOptions
+}
+
+func NewFromMap(o *map[string]string) Interface {
+ return (*gitPushOptions)(o)
+}
+
+func (o *gitPushOptions) ReadEnv() Interface {
+ if pushCount, err := strconv.Atoi(os.Getenv(EnvCount)); err == nil {
+ for idx := 0; idx < pushCount; idx++ {
+ _ = o.Parse(os.Getenv(fmt.Sprintf(EnvFormat, idx)))
+ }
+ }
+ return o
+}
+
+func (o *gitPushOptions) Parse(data string) bool {
+ key, value, found := strings.Cut(data, "=")
+ if !found {
+ value = "true"
+ }
+ switch Key(key) {
+ case RepoPrivate:
+ case RepoTemplate:
+ case AgitTopic:
+ case AgitForcePush:
+ case AgitTitle:
+ case AgitDescription:
+ default:
+ return false
+ }
+ (*o)[key] = value
+ return true
+}
+
+func (o gitPushOptions) Map() map[string]string {
+ return o
+}
+
+func (o gitPushOptions) ChangeRepoSettings() bool {
+ if o.Empty() {
+ return false
+ }
+ for _, key := range []Key{RepoPrivate, RepoTemplate} {
+ _, ok := o[string(key)]
+ if ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (o gitPushOptions) Empty() bool {
+ return len(o) == 0
+}
+
+func (o gitPushOptions) GetBool(key Key, def bool) bool {
+ if val, ok := o[string(key)]; ok {
+ if b, err := strconv.ParseBool(val); err == nil {
+ return b
+ }
+ }
+ return def
+}
+
+func (o gitPushOptions) GetString(key Key) (string, bool) {
+ val, ok := o[string(key)]
+ return val, ok
+}
diff --git a/modules/git/pushoptions/pushoptions_test.go b/modules/git/pushoptions/pushoptions_test.go
new file mode 100644
index 0000000..49bf2d2
--- /dev/null
+++ b/modules/git/pushoptions/pushoptions_test.go
@@ -0,0 +1,125 @@
+// Copyright twenty-panda <twenty-panda@posteo.com>
+// SPDX-License-Identifier: MIT
+
+package pushoptions
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEmpty(t *testing.T) {
+ options := New()
+ assert.True(t, options.Empty())
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ assert.False(t, options.Empty())
+}
+
+func TestToAndFromMap(t *testing.T) {
+ options := New()
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ actual := options.Map()
+ expected := map[string]string{string(RepoPrivate): "true"}
+ assert.EqualValues(t, expected, actual)
+ assert.EqualValues(t, expected, NewFromMap(&actual).Map())
+}
+
+func TestChangeRepositorySettings(t *testing.T) {
+ options := New()
+ assert.False(t, options.ChangeRepoSettings())
+ assert.True(t, options.Parse(fmt.Sprintf("%v=description", AgitDescription)))
+ assert.False(t, options.ChangeRepoSettings())
+
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ assert.True(t, options.ChangeRepoSettings())
+
+ options = New()
+ options.Parse(fmt.Sprintf("%v", RepoTemplate))
+ assert.True(t, options.ChangeRepoSettings())
+}
+
+func TestParse(t *testing.T) {
+ t.Run("no key", func(t *testing.T) {
+ options := New()
+
+ val, ok := options.GetString(RepoPrivate)
+ assert.False(t, ok)
+ assert.Equal(t, "", val)
+
+ assert.True(t, options.GetBool(RepoPrivate, true))
+ assert.False(t, options.GetBool(RepoPrivate, false))
+ })
+
+ t.Run("key=value", func(t *testing.T) {
+ options := New()
+
+ topic := "TOPIC"
+ assert.True(t, options.Parse(fmt.Sprintf("%v=%s", AgitTopic, topic)))
+ val, ok := options.GetString(AgitTopic)
+ assert.True(t, ok)
+ assert.Equal(t, topic, val)
+ })
+
+ t.Run("key=true", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v=true", RepoPrivate)))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ assert.True(t, options.Parse(fmt.Sprintf("%v=TRUE", RepoTemplate)))
+ assert.True(t, options.GetBool(RepoTemplate, false))
+ })
+
+ t.Run("key=false", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v=false", RepoPrivate)))
+ assert.False(t, options.GetBool(RepoPrivate, true))
+ })
+
+ t.Run("key", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v", RepoPrivate)))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ })
+
+ t.Run("unknown keys are ignored", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Empty())
+ assert.False(t, options.Parse("unknown=value"))
+ assert.True(t, options.Empty())
+ })
+}
+
+func TestReadEnv(t *testing.T) {
+ t.Setenv(envPrefix+"_0", fmt.Sprintf("%v=true", AgitForcePush))
+ t.Setenv(envPrefix+"_1", fmt.Sprintf("%v", RepoPrivate))
+ t.Setenv(envPrefix+"_2", fmt.Sprintf("%v=equal=in string", AgitTitle))
+ t.Setenv(envPrefix+"_3", "not=valid")
+ t.Setenv(envPrefix+"_4", fmt.Sprintf("%v=description", AgitDescription))
+ t.Setenv(EnvCount, "5")
+
+ options := New().ReadEnv()
+
+ assert.True(t, options.GetBool(AgitForcePush, false))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ assert.False(t, options.GetBool(RepoTemplate, false))
+
+ {
+ val, ok := options.GetString(AgitTitle)
+ assert.True(t, ok)
+ assert.Equal(t, "equal=in string", val)
+ }
+ {
+ val, ok := options.GetString(AgitDescription)
+ assert.True(t, ok)
+ assert.Equal(t, "description", val)
+ }
+ {
+ _, ok := options.GetString(AgitTopic)
+ assert.False(t, ok)
+ }
+}
diff --git a/modules/git/ref.go b/modules/git/ref.go
new file mode 100644
index 0000000..2db630e
--- /dev/null
+++ b/modules/git/ref.go
@@ -0,0 +1,214 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ // RemotePrefix is the base directory of the remotes information of git.
+ RemotePrefix = "refs/remotes/"
+ // PullPrefix is the base directory of the pull information of git.
+ PullPrefix = "refs/pull/"
+)
+
+// refNamePatternInvalid is regular expression with unallowed characters in git reference name
+// They cannot have ASCII control characters (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^, or colon : anywhere.
+// They cannot have question-mark ?, asterisk *, or open bracket [ anywhere
+var refNamePatternInvalid = regexp.MustCompile(
+ `[\000-\037\177 \\~^:?*[]|` + // No absolutely invalid characters
+ `(?:^[/.])|` + // Not HasPrefix("/") or "."
+ `(?:/\.)|` + // no "/."
+ `(?:\.lock$)|(?:\.lock/)|` + // No ".lock/"" or ".lock" at the end
+ `(?:\.\.)|` + // no ".." anywhere
+ `(?://)|` + // no "//" anywhere
+ `(?:@{)|` + // no "@{"
+ `(?:[/.]$)|` + // no terminal '/' or '.'
+ `(?:^@$)`) // Not "@"
+
+// IsValidRefPattern ensures that the provided string could be a valid reference
+func IsValidRefPattern(name string) bool {
+ return !refNamePatternInvalid.MatchString(name)
+}
+
+func SanitizeRefPattern(name string) string {
+ return refNamePatternInvalid.ReplaceAllString(name, "_")
+}
+
+// Reference represents a Git ref.
+type Reference struct {
+ Name string
+ repo *Repository
+ Object ObjectID // The id of this commit object
+ Type string
+}
+
+// Commit return the commit of the reference
+func (ref *Reference) Commit() (*Commit, error) {
+ return ref.repo.getCommit(ref.Object)
+}
+
+// ShortName returns the short name of the reference
+func (ref *Reference) ShortName() string {
+ return RefName(ref.Name).ShortName()
+}
+
+// RefGroup returns the group type of the reference
+func (ref *Reference) RefGroup() string {
+ return RefName(ref.Name).RefGroup()
+}
+
+// ForPrefix special ref to create a pull request: refs/for/<target-branch>/<topic-branch>
+// or refs/for/<targe-branch> -o topic='<topic-branch>'
+const ForPrefix = "refs/for/"
+
+// TODO: /refs/for-review for suggest change interface
+
+// RefName represents a full git reference name
+type RefName string
+
+func RefNameFromBranch(shortName string) RefName {
+ return RefName(BranchPrefix + shortName)
+}
+
+func RefNameFromTag(shortName string) RefName {
+ return RefName(TagPrefix + shortName)
+}
+
+func (ref RefName) String() string {
+ return string(ref)
+}
+
+func (ref RefName) IsBranch() bool {
+ return strings.HasPrefix(string(ref), BranchPrefix)
+}
+
+func (ref RefName) IsTag() bool {
+ return strings.HasPrefix(string(ref), TagPrefix)
+}
+
+func (ref RefName) IsRemote() bool {
+ return strings.HasPrefix(string(ref), RemotePrefix)
+}
+
+func (ref RefName) IsPull() bool {
+ return strings.HasPrefix(string(ref), PullPrefix) && strings.IndexByte(string(ref)[len(PullPrefix):], '/') > -1
+}
+
+func (ref RefName) IsFor() bool {
+ return strings.HasPrefix(string(ref), ForPrefix)
+}
+
+func (ref RefName) nameWithoutPrefix(prefix string) string {
+ if strings.HasPrefix(string(ref), prefix) {
+ return strings.TrimPrefix(string(ref), prefix)
+ }
+ return ""
+}
+
+// TagName returns simple tag name if it's an operation to a tag
+func (ref RefName) TagName() string {
+ return ref.nameWithoutPrefix(TagPrefix)
+}
+
+// BranchName returns simple branch name if it's an operation to branch
+func (ref RefName) BranchName() string {
+ return ref.nameWithoutPrefix(BranchPrefix)
+}
+
+// PullName returns the pull request name part of refs like refs/pull/<pull_name>/head
+func (ref RefName) PullName() string {
+ refName := string(ref)
+ lastIdx := strings.LastIndexByte(refName[len(PullPrefix):], '/')
+ if strings.HasPrefix(refName, PullPrefix) && lastIdx > -1 {
+ return refName[len(PullPrefix) : lastIdx+len(PullPrefix)]
+ }
+ return ""
+}
+
+// ForBranchName returns the branch name part of refs like refs/for/<branch_name>
+func (ref RefName) ForBranchName() string {
+ return ref.nameWithoutPrefix(ForPrefix)
+}
+
+func (ref RefName) RemoteName() string {
+ return ref.nameWithoutPrefix(RemotePrefix)
+}
+
+// ShortName returns the short name of the reference name
+func (ref RefName) ShortName() string {
+ refName := string(ref)
+ if ref.IsBranch() {
+ return ref.BranchName()
+ }
+ if ref.IsTag() {
+ return ref.TagName()
+ }
+ if ref.IsRemote() {
+ return ref.RemoteName()
+ }
+ if ref.IsPull() {
+ return ref.PullName()
+ }
+ if ref.IsFor() {
+ return ref.ForBranchName()
+ }
+
+ return refName
+}
+
+// RefGroup returns the group type of the reference
+// Using the name of the directory under .git/refs
+func (ref RefName) RefGroup() string {
+ if ref.IsBranch() {
+ return "heads"
+ }
+ if ref.IsTag() {
+ return "tags"
+ }
+ if ref.IsRemote() {
+ return "remotes"
+ }
+ if ref.IsPull() {
+ return "pull"
+ }
+ if ref.IsFor() {
+ return "for"
+ }
+ return ""
+}
+
+// RefType returns the simple ref type of the reference, e.g. branch, tag
+// It's different from RefGroup, which is using the name of the directory under .git/refs
+// Here we using branch but not heads, using tag but not tags
+func (ref RefName) RefType() string {
+ var refType string
+ if ref.IsBranch() {
+ refType = "branch"
+ } else if ref.IsTag() {
+ refType = "tag"
+ }
+ return refType
+}
+
+// RefURL returns the absolute URL for a ref in a repository
+func RefURL(repoURL, ref string) string {
+ refFullName := RefName(ref)
+ refName := util.PathEscapeSegments(refFullName.ShortName())
+ switch {
+ case refFullName.IsBranch():
+ return repoURL + "/src/branch/" + refName
+ case refFullName.IsTag():
+ return repoURL + "/src/tag/" + refName
+ case !Sha1ObjectFormat.IsValid(ref):
+ // assume they mean a branch
+ return repoURL + "/src/branch/" + refName
+ default:
+ return repoURL + "/src/commit/" + refName
+ }
+}
diff --git a/modules/git/ref_test.go b/modules/git/ref_test.go
new file mode 100644
index 0000000..58f679b
--- /dev/null
+++ b/modules/git/ref_test.go
@@ -0,0 +1,38 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRefName(t *testing.T) {
+ // Test branch names (with and without slash).
+ assert.Equal(t, "foo", RefName("refs/heads/foo").BranchName())
+ assert.Equal(t, "feature/foo", RefName("refs/heads/feature/foo").BranchName())
+
+ // Test tag names (with and without slash).
+ assert.Equal(t, "foo", RefName("refs/tags/foo").TagName())
+ assert.Equal(t, "release/foo", RefName("refs/tags/release/foo").TagName())
+
+ // Test pull names
+ assert.Equal(t, "1", RefName("refs/pull/1/head").PullName())
+ assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").PullName())
+
+ // Test for branch names
+ assert.Equal(t, "main", RefName("refs/for/main").ForBranchName())
+ assert.Equal(t, "my/branch", RefName("refs/for/my/branch").ForBranchName())
+
+ // Test commit hashes.
+ assert.Equal(t, "c0ffee", RefName("c0ffee").ShortName())
+}
+
+func TestRefURL(t *testing.T) {
+ repoURL := "/user/repo"
+ assert.Equal(t, repoURL+"/src/branch/foo", RefURL(repoURL, "refs/heads/foo"))
+ assert.Equal(t, repoURL+"/src/tag/foo", RefURL(repoURL, "refs/tags/foo"))
+ assert.Equal(t, repoURL+"/src/commit/c0ffee", RefURL(repoURL, "c0ffee"))
+}
diff --git a/modules/git/remote.go b/modules/git/remote.go
new file mode 100644
index 0000000..3585313
--- /dev/null
+++ b/modules/git/remote.go
@@ -0,0 +1,39 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+
+ giturl "code.gitea.io/gitea/modules/git/url"
+)
+
+// GetRemoteAddress returns remote url of git repository in the repoPath with special remote name
+func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) {
+ var cmd *Command
+ if CheckGitVersionAtLeast("2.7") == nil {
+ cmd = NewCommand(ctx, "remote", "get-url").AddDynamicArguments(remoteName)
+ } else {
+ cmd = NewCommand(ctx, "config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
+ }
+
+ result, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return "", err
+ }
+
+ if len(result) > 0 {
+ result = result[:len(result)-1]
+ }
+ return result, nil
+}
+
+// GetRemoteURL returns the url of a specific remote of the repository.
+func GetRemoteURL(ctx context.Context, repoPath, remoteName string) (*giturl.GitURL, error) {
+ addr, err := GetRemoteAddress(ctx, repoPath, remoteName)
+ if err != nil {
+ return nil, err
+ }
+ return giturl.Parse(addr)
+}
diff --git a/modules/git/repo.go b/modules/git/repo.go
new file mode 100644
index 0000000..84db08d
--- /dev/null
+++ b/modules/git/repo.go
@@ -0,0 +1,342 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// GPGSettings represents the default GPG settings for this repository
+type GPGSettings struct {
+ Sign bool
+ KeyID string
+ Email string
+ Name string
+ PublicKeyContent string
+}
+
+const prettyLogFormat = `--pretty=format:%H`
+
+// GetAllCommitsCount returns count of all commits in repository
+func (repo *Repository) GetAllCommitsCount() (int64, error) {
+ return AllCommitsCount(repo.Ctx, repo.Path, false)
+}
+
+func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, error) {
+ var commits []*Commit
+ if len(logs) == 0 {
+ return commits, nil
+ }
+
+ parts := bytes.Split(logs, []byte{'\n'})
+
+ for _, commitID := range parts {
+ commit, err := repo.GetCommit(string(commitID))
+ if err != nil {
+ return nil, err
+ }
+ commits = append(commits, commit)
+ }
+
+ return commits, nil
+}
+
+// IsRepoURLAccessible checks if given repository URL is accessible.
+func IsRepoURLAccessible(ctx context.Context, url string) bool {
+ _, _, err := NewCommand(ctx, "ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(nil)
+ return err == nil
+}
+
+// InitRepository initializes a new Git repository.
+func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error {
+ err := os.MkdirAll(repoPath, os.ModePerm)
+ if err != nil {
+ return err
+ }
+
+ cmd := NewCommand(ctx, "init")
+
+ if !IsValidObjectFormat(objectFormatName) {
+ return fmt.Errorf("invalid object format: %s", objectFormatName)
+ }
+ if SupportHashSha256 {
+ cmd.AddOptionValues("--object-format", objectFormatName)
+ }
+
+ if bare {
+ cmd.AddArguments("--bare")
+ }
+ _, _, err = cmd.RunStdString(&RunOpts{Dir: repoPath})
+ return err
+}
+
+// IsEmpty Check if repository is empty.
+func (repo *Repository) IsEmpty() (bool, error) {
+ var errbuf, output strings.Builder
+ if err := NewCommand(repo.Ctx).AddOptionFormat("--git-dir=%s", repo.Path).AddArguments("rev-list", "-n", "1", "--all").
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: &output,
+ Stderr: &errbuf,
+ }); err != nil {
+ if (err.Error() == "exit status 1" && strings.TrimSpace(errbuf.String()) == "") || err.Error() == "exit status 129" {
+ // git 2.11 exits with 129 if the repo is empty
+ return true, nil
+ }
+ return true, fmt.Errorf("check empty: %w - %s", err, errbuf.String())
+ }
+
+ return strings.TrimSpace(output.String()) == "", nil
+}
+
+// CloneRepoOptions options when clone a repository
+type CloneRepoOptions struct {
+ Timeout time.Duration
+ Mirror bool
+ Bare bool
+ Quiet bool
+ Branch string
+ Shared bool
+ NoCheckout bool
+ Depth int
+ Filter string
+ SkipTLSVerify bool
+}
+
+// Clone clones original repository to target path.
+func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
+ return CloneWithArgs(ctx, globalCommandArgs, from, to, opts)
+}
+
+// CloneWithArgs original repository to target path.
+func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, opts CloneRepoOptions) (err error) {
+ toDir := path.Dir(to)
+ if err = os.MkdirAll(toDir, os.ModePerm); err != nil {
+ return err
+ }
+
+ cmd := NewCommandContextNoGlobals(ctx, args...).AddArguments("clone")
+ if opts.SkipTLSVerify {
+ cmd.AddArguments("-c", "http.sslVerify=false")
+ }
+ if opts.Mirror {
+ cmd.AddArguments("--mirror")
+ }
+ if opts.Bare {
+ cmd.AddArguments("--bare")
+ }
+ if opts.Quiet {
+ cmd.AddArguments("--quiet")
+ }
+ if opts.Shared {
+ cmd.AddArguments("-s")
+ }
+ if opts.NoCheckout {
+ cmd.AddArguments("--no-checkout")
+ }
+ if opts.Depth > 0 {
+ cmd.AddArguments("--depth").AddDynamicArguments(strconv.Itoa(opts.Depth))
+ }
+ if opts.Filter != "" {
+ cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
+ }
+ if len(opts.Branch) > 0 {
+ cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
+ }
+ cmd.AddDashesAndList(from, to)
+
+ if strings.Contains(from, "://") && strings.Contains(from, "@") {
+ cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, util.SanitizeCredentialURLs(from), to, opts.Shared, opts.Mirror, opts.Depth))
+ } else {
+ cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, from, to, opts.Shared, opts.Mirror, opts.Depth))
+ }
+
+ if opts.Timeout <= 0 {
+ opts.Timeout = -1
+ }
+
+ envs := os.Environ()
+ u, err := url.Parse(from)
+ if err == nil {
+ envs = proxy.EnvWithProxy(u)
+ }
+
+ stderr := new(bytes.Buffer)
+ if err = cmd.Run(&RunOpts{
+ Timeout: opts.Timeout,
+ Env: envs,
+ Stdout: io.Discard,
+ Stderr: stderr,
+ }); err != nil {
+ return ConcatenateError(err, stderr.String())
+ }
+ return nil
+}
+
+// PushOptions options when push to remote
+type PushOptions struct {
+ Remote string
+ Branch string
+ Force bool
+ Mirror bool
+ Env []string
+ Timeout time.Duration
+ PrivateKeyPath string
+}
+
+// Push pushs local commits to given remote branch.
+func Push(ctx context.Context, repoPath string, opts PushOptions) error {
+ cmd := NewCommand(ctx, "push")
+
+ if opts.PrivateKeyPath != "" {
+ // Preserve the behavior that existing environments are used if no
+ // environments are passed.
+ if len(opts.Env) == 0 {
+ opts.Env = os.Environ()
+ }
+
+ // Use environment because it takes precedence over using -c core.sshcommand
+ // and it's possible that a system might have an existing GIT_SSH_COMMAND
+ // environment set.
+ opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
+ fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
+ " -o IdentitiesOnly=yes"+
+ // This will store new SSH host keys and verify connections to existing
+ // host keys, but it doesn't allow replacement of existing host keys. This
+ // means TOFU is used for Git over SSH pushes.
+ " -o StrictHostKeyChecking=accept-new"+
+ " -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
+ }
+
+ if opts.Force {
+ cmd.AddArguments("-f")
+ }
+ if opts.Mirror {
+ cmd.AddArguments("--mirror")
+ }
+ remoteBranchArgs := []string{opts.Remote}
+ if len(opts.Branch) > 0 {
+ remoteBranchArgs = append(remoteBranchArgs, opts.Branch)
+ }
+ cmd.AddDashesAndList(remoteBranchArgs...)
+
+ if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") {
+ cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, util.SanitizeCredentialURLs(opts.Remote), opts.Force, opts.Mirror))
+ } else {
+ cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror))
+ }
+
+ stdout, stderr, err := cmd.RunStdString(&RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
+ if err != nil {
+ if strings.Contains(stderr, "non-fast-forward") {
+ return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
+ } else if strings.Contains(stderr, "! [remote rejected]") {
+ err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err}
+ err.GenerateMessage()
+ return err
+ } else if strings.Contains(stderr, "matches more than one") {
+ return &ErrMoreThanOne{StdOut: stdout, StdErr: stderr, Err: err}
+ }
+ return fmt.Errorf("push failed: %w - %s\n%s", err, stderr, stdout)
+ }
+
+ return nil
+}
+
+// GetLatestCommitTime returns time for latest commit in repository (across all branches)
+func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) {
+ cmd := NewCommand(ctx, "for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return time.Time{}, err
+ }
+ commitTime := strings.TrimSpace(stdout)
+ return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
+}
+
+// DivergeObject represents commit count diverging commits
+type DivergeObject struct {
+ Ahead int
+ Behind int
+}
+
+// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch
+func GetDivergingCommits(ctx context.Context, repoPath, baseBranch, targetBranch string) (do DivergeObject, err error) {
+ cmd := NewCommand(ctx, "rev-list", "--count", "--left-right").
+ AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--")
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return do, err
+ }
+ left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t")
+ if !found {
+ return do, fmt.Errorf("git rev-list output is missing a tab: %q", stdout)
+ }
+
+ do.Behind, err = strconv.Atoi(left)
+ if err != nil {
+ return do, err
+ }
+ do.Ahead, err = strconv.Atoi(right)
+ if err != nil {
+ return do, err
+ }
+ return do, nil
+}
+
+// CreateBundle create bundle content to the target path
+func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error {
+ tmp, err := os.MkdirTemp(os.TempDir(), "gitea-bundle")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+
+ env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects"))
+ _, _, err = NewCommand(ctx, "init", "--bare").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ _, _, err = NewCommand(ctx, "reset", "--soft").AddDynamicArguments(commit).RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ _, _, err = NewCommand(ctx, "branch", "-m", "bundle").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ tmpFile := filepath.Join(tmp, "bundle")
+ _, _, err = NewCommand(ctx, "bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ fi, err := os.Open(tmpFile)
+ if err != nil {
+ return err
+ }
+ defer fi.Close()
+
+ _, err = io.Copy(out, fi)
+ return err
+}
diff --git a/modules/git/repo_archive.go b/modules/git/repo_archive.go
new file mode 100644
index 0000000..1bf1aa4
--- /dev/null
+++ b/modules/git/repo_archive.go
@@ -0,0 +1,80 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ArchiveType archive types
+type ArchiveType int
+
+const (
+ // ZIP zip archive type
+ ZIP ArchiveType = iota + 1
+ // TARGZ tar gz archive type
+ TARGZ
+ // BUNDLE bundle archive type
+ BUNDLE
+)
+
+// String converts an ArchiveType to string
+func (a ArchiveType) String() string {
+ switch a {
+ case ZIP:
+ return "zip"
+ case TARGZ:
+ return "tar.gz"
+ case BUNDLE:
+ return "bundle"
+ }
+ return "unknown"
+}
+
+func ToArchiveType(s string) ArchiveType {
+ switch s {
+ case "zip":
+ return ZIP
+ case "tar.gz":
+ return TARGZ
+ case "bundle":
+ return BUNDLE
+ }
+ return 0
+}
+
+// CreateArchive create archive content to the target path
+func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, target io.Writer, usePrefix bool, commitID string) error {
+ if format.String() == "unknown" {
+ return fmt.Errorf("unknown format: %v", format)
+ }
+
+ cmd := NewCommand(ctx, "archive")
+ if usePrefix {
+ cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/")
+ }
+ cmd.AddOptionFormat("--format=%s", format.String())
+ cmd.AddDynamicArguments(commitID)
+
+ // Avoid LFS hooks getting installed because of /etc/gitconfig, which can break pull requests.
+ env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
+
+ var stderr strings.Builder
+ err := cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: target,
+ Stderr: &stderr,
+ Env: env,
+ })
+ if err != nil {
+ return ConcatenateError(err, stderr.String())
+ }
+ return nil
+}
diff --git a/modules/git/repo_attribute.go b/modules/git/repo_attribute.go
new file mode 100644
index 0000000..3ccc1b8
--- /dev/null
+++ b/modules/git/repo_attribute.go
@@ -0,0 +1,286 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/optional"
+)
+
+var LinguistAttributes = []string{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language", "linguist-documentation", "linguist-detectable"}
+
+// newCheckAttrStdoutReader parses the nul-byte separated output of git check-attr on each call of
+// the returned function. The first reading error will stop the reading and be returned on all
+// subsequent calls.
+func newCheckAttrStdoutReader(r io.Reader, count int) func() (map[string]GitAttribute, error) {
+ scanner := bufio.NewScanner(r)
+
+ // adapted from bufio.ScanLines to split on nul-byte \x00
+ scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ if atEOF && len(data) == 0 {
+ return 0, nil, nil
+ }
+ if i := bytes.IndexByte(data, '\x00'); i >= 0 {
+ // We have a full nul-terminated line.
+ return i + 1, data[0:i], nil
+ }
+ // If we're at EOF, we have a final, non-terminated line. Return it.
+ if atEOF {
+ return len(data), data, nil
+ }
+ // Request more data.
+ return 0, nil, nil
+ })
+
+ var err error
+ nextText := func() string {
+ if err != nil {
+ return ""
+ }
+ if !scanner.Scan() {
+ err = scanner.Err()
+ if err == nil {
+ err = io.ErrUnexpectedEOF
+ }
+ return ""
+ }
+ return scanner.Text()
+ }
+ nextAttribute := func() (string, GitAttribute, error) {
+ nextText() // discard filename
+ key := nextText()
+ value := GitAttribute(nextText())
+ return key, value, err
+ }
+ return func() (map[string]GitAttribute, error) {
+ values := make(map[string]GitAttribute, count)
+ for range count {
+ k, v, err := nextAttribute()
+ if err != nil {
+ return values, err
+ }
+ values[k] = v
+ }
+ return values, scanner.Err()
+ }
+}
+
+// GitAttribute exposes an attribute from the .gitattribute file
+type GitAttribute string //nolint:revive
+
+// IsSpecified returns true if the gitattribute is set and not empty
+func (ca GitAttribute) IsSpecified() bool {
+ return ca != "" && ca != "unspecified"
+}
+
+// String returns the value of the attribute or "" if unspecified
+func (ca GitAttribute) String() string {
+ if !ca.IsSpecified() {
+ return ""
+ }
+ return string(ca)
+}
+
+// Prefix returns the value of the attribute before any question mark '?'
+//
+// sometimes used within gitlab-language: https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
+func (ca GitAttribute) Prefix() string {
+ s := ca.String()
+ if i := strings.IndexByte(s, '?'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+// Bool returns true if "set"/"true", false if "unset"/"false", none otherwise
+func (ca GitAttribute) Bool() optional.Option[bool] {
+ switch ca {
+ case "set", "true":
+ return optional.Some(true)
+ case "unset", "false":
+ return optional.Some(false)
+ }
+ return optional.None[bool]()
+}
+
+// gitCheckAttrCommand prepares the "git check-attr" command for later use as one-shot or streaming
+// instantiation.
+func (repo *Repository) gitCheckAttrCommand(treeish string, attributes ...string) (*Command, *RunOpts, context.CancelFunc, error) {
+ if len(attributes) == 0 {
+ return nil, nil, nil, fmt.Errorf("no provided attributes to check-attr")
+ }
+
+ env := os.Environ()
+ var removeTempFiles context.CancelFunc = func() {}
+
+ // git < 2.40 cannot run check-attr on bare repo, but needs INDEX + WORK_TREE
+ hasIndex := treeish == ""
+ if !hasIndex && !SupportCheckAttrOnBare {
+ indexFilename, worktree, cancel, err := repo.ReadTreeToTemporaryIndex(treeish)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ removeTempFiles = cancel
+
+ env = append(env, "GIT_INDEX_FILE="+indexFilename, "GIT_WORK_TREE="+worktree)
+
+ hasIndex = true
+
+ // clear treeish to read from provided index/work_tree
+ treeish = ""
+ }
+
+ cmd := NewCommand(repo.Ctx, "check-attr", "-z")
+
+ if hasIndex {
+ cmd.AddArguments("--cached")
+ }
+
+ if len(treeish) > 0 {
+ cmd.AddArguments("--source")
+ cmd.AddDynamicArguments(treeish)
+ }
+ cmd.AddDynamicArguments(attributes...)
+
+ // Version 2.43.1 has a bug where the behavior of `GIT_FLUSH` is flipped.
+ // Ref: https://lore.kernel.org/git/CABn0oJvg3M_kBW-u=j3QhKnO=6QOzk-YFTgonYw_UvFS1NTX4g@mail.gmail.com
+ if InvertedGitFlushEnv {
+ env = append(env, "GIT_FLUSH=0")
+ } else {
+ env = append(env, "GIT_FLUSH=1")
+ }
+
+ return cmd, &RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ }, removeTempFiles, nil
+}
+
+// GitAttributeFirst returns the first specified attribute of the given filename.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributeFirst(treeish, filename string, attributes ...string) (GitAttribute, error) {
+ values, err := repo.GitAttributes(treeish, filename, attributes...)
+ if err != nil {
+ return "", err
+ }
+ for _, a := range attributes {
+ if values[a].IsSpecified() {
+ return values[a], nil
+ }
+ }
+ return "", nil
+}
+
+// GitAttributes returns the gitattribute of the given filename.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributes(treeish, filename string, attributes ...string) (map[string]GitAttribute, error) {
+ cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...)
+ if err != nil {
+ return nil, err
+ }
+ defer removeTempFiles()
+
+ stdOut := new(bytes.Buffer)
+ runOpts.Stdout = stdOut
+
+ stdErr := new(bytes.Buffer)
+ runOpts.Stderr = stdErr
+
+ cmd.AddDashesAndList(filename)
+
+ if err := cmd.Run(runOpts); err != nil {
+ return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String())
+ }
+
+ return newCheckAttrStdoutReader(stdOut, len(attributes))()
+}
+
+// GitAttributeChecker creates an AttributeChecker for the given repository and provided commit ID
+// to retrieve the attributes of multiple files. The AttributeChecker must be closed after use.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributeChecker(treeish string, attributes ...string) (AttributeChecker, error) {
+ cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...)
+ if err != nil {
+ return AttributeChecker{}, err
+ }
+
+ cmd.AddArguments("--stdin")
+
+ // os.Pipe is needed (and not io.Pipe), otherwise cmd.Wait will wait for the stdinReader
+ // to be closed before returning (which would require another goroutine)
+ // https://go.dev/issue/23019
+ stdinReader, stdinWriter, err := os.Pipe() // reader closed in goroutine / writer closed on ac.Close
+ if err != nil {
+ return AttributeChecker{}, err
+ }
+ stdoutReader, stdoutWriter := io.Pipe() // closed in goroutine
+
+ ac := AttributeChecker{
+ removeTempFiles: removeTempFiles, // called on ac.Close
+ stdinWriter: stdinWriter,
+ readStdout: newCheckAttrStdoutReader(stdoutReader, len(attributes)),
+ err: &atomic.Value{},
+ }
+
+ go func() {
+ defer stdinReader.Close()
+ defer stdoutWriter.Close() // in case of a panic (no-op if already closed by CloseWithError at the end)
+
+ stdErr := new(bytes.Buffer)
+ runOpts.Stdin = stdinReader
+ runOpts.Stdout = stdoutWriter
+ runOpts.Stderr = stdErr
+
+ err := cmd.Run(runOpts)
+
+ // if the context was cancelled, Run error is irrelevant
+ if e := cmd.parentContext.Err(); e != nil {
+ err = e
+ }
+
+ if err != nil { // decorate the returned error
+ err = fmt.Errorf("git check-attr (stderr: %q): %w", strings.TrimSpace(stdErr.String()), err)
+ ac.err.Store(err)
+ }
+ stdoutWriter.CloseWithError(err)
+ }()
+
+ return ac, nil
+}
+
+type AttributeChecker struct {
+ removeTempFiles context.CancelFunc
+ stdinWriter io.WriteCloser
+ readStdout func() (map[string]GitAttribute, error)
+ err *atomic.Value
+}
+
+func (ac AttributeChecker) CheckPath(path string) (map[string]GitAttribute, error) {
+ if _, err := ac.stdinWriter.Write([]byte(path + "\x00")); err != nil {
+ // try to return the Run error if available, since it is likely more helpful
+ // than just "broken pipe"
+ if aerr, _ := ac.err.Load().(error); aerr != nil {
+ return nil, aerr
+ }
+ return nil, fmt.Errorf("git check-attr: %w", err)
+ }
+
+ return ac.readStdout()
+}
+
+func (ac AttributeChecker) Close() error {
+ ac.removeTempFiles()
+ return ac.stdinWriter.Close()
+}
diff --git a/modules/git/repo_attribute_test.go b/modules/git/repo_attribute_test.go
new file mode 100644
index 0000000..fa34164
--- /dev/null
+++ b/modules/git/repo_attribute_test.go
@@ -0,0 +1,351 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewCheckAttrStdoutReader(t *testing.T) {
+ t.Run("two_times", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ ".gitignore\x00linguist-vendored\x00unspecified\x00"+
+ ".gitignore\x00linguist-vendored\x00specified",
+ ), 1)
+
+ // first read
+ attr, err := read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("unspecified"),
+ }, attr)
+
+ // second read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("specified"),
+ }, attr)
+ })
+ t.Run("incomplete", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ "filename\x00linguist-vendored",
+ ), 1)
+
+ _, err := read()
+ assert.Equal(t, io.ErrUnexpectedEOF, err)
+ })
+ t.Run("three_times", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ "shouldbe.vendor\x00linguist-vendored\x00set\x00"+
+ "shouldbe.vendor\x00linguist-generated\x00unspecified\x00"+
+ "shouldbe.vendor\x00linguist-language\x00unspecified\x00",
+ ), 1)
+
+ // first read
+ attr, err := read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("set"),
+ }, attr)
+
+ // second read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-generated": GitAttribute("unspecified"),
+ }, attr)
+
+ // third read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-language": GitAttribute("unspecified"),
+ }, attr)
+ })
+}
+
+func TestGitAttributeBareNonBare(t *testing.T) {
+ if !SupportCheckAttrOnBare {
+ t.Skip("git check-attr supported on bare repo starting with git 2.40")
+ }
+
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ for _, commitID := range []string{
+ "8fee858da5796dfb37704761701bb8e800ad9ef3",
+ "341fca5b5ea3de596dc483e54c2db28633cd2f97",
+ } {
+ bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+
+ defer test.MockVariableValue(&SupportCheckAttrOnBare, false)()
+ cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, cloneStats, bareStats)
+ refStats := cloneStats
+
+ t.Run("GitAttributeChecker/"+commitID+"/SupportBare", func(t *testing.T) {
+ bareChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...)
+ require.NoError(t, err)
+ defer bareChecker.Close()
+
+ bareStats, err := bareChecker.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.EqualValues(t, refStats, bareStats)
+ })
+ t.Run("GitAttributeChecker/"+commitID+"/NoBareSupport", func(t *testing.T) {
+ defer test.MockVariableValue(&SupportCheckAttrOnBare, false)()
+ cloneChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...)
+ require.NoError(t, err)
+ defer cloneChecker.Close()
+
+ cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+
+ assert.EqualValues(t, refStats, cloneStats)
+ })
+ }
+}
+
+func TestGitAttributes(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ attr, err := gitRepo.GitAttributes("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+ assert.EqualValues(t, map[string]GitAttribute{
+ "gitlab-language": "unspecified",
+ "linguist-detectable": "unspecified",
+ "linguist-documentation": "unspecified",
+ "linguist-generated": "unspecified",
+ "linguist-language": "Python",
+ "linguist-vendored": "unspecified",
+ }, attr)
+
+ attr, err = gitRepo.GitAttributes("341fca5b5ea3de596dc483e54c2db28633cd2f97", "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+ assert.EqualValues(t, map[string]GitAttribute{
+ "gitlab-language": "unspecified",
+ "linguist-detectable": "unspecified",
+ "linguist-documentation": "unspecified",
+ "linguist-generated": "unspecified",
+ "linguist-language": "Cobra",
+ "linguist-vendored": "unspecified",
+ }, attr)
+}
+
+func TestGitAttributeFirst(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ t.Run("first is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-language", "gitlab-language")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", language.String())
+ })
+
+ t.Run("second is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "gitlab-language", "linguist-language")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", language.String())
+ })
+
+ t.Run("none is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-detectable", "gitlab-language", "non-existing")
+ require.NoError(t, err)
+ assert.Equal(t, "", language.String())
+ })
+}
+
+func TestGitAttributeStruct(t *testing.T) {
+ assert.Equal(t, "", GitAttribute("").String())
+ assert.Equal(t, "", GitAttribute("unspecified").String())
+
+ assert.Equal(t, "python", GitAttribute("python").String())
+
+ assert.Equal(t, "text?token=Error", GitAttribute("text?token=Error").String())
+ assert.Equal(t, "text", GitAttribute("text?token=Error").Prefix())
+}
+
+func TestGitAttributeCheckerError(t *testing.T) {
+ prepareRepo := func(t *testing.T) *Repository {
+ t.Helper()
+ path := t.TempDir()
+
+ // we can't use unittest.CopyDir because of an import cycle (git.Init in unittest)
+ require.NoError(t, CopyFS(path, os.DirFS(filepath.Join(testReposDir, "language_stats_repo"))))
+
+ gitRepo, err := openRepositoryWithDefaultContext(path)
+ require.NoError(t, err)
+ return gitRepo
+ }
+
+ t.Run("RemoveAll/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.RemoveAll(gitRepo.Path))
+
+ ac, err := gitRepo.GitAttributeChecker("", "linguist-language")
+ require.NoError(t, err)
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `git check-attr (stderr: ""):`)
+ })
+
+ t.Run("RemoveAll/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("", "linguist-language")
+ require.NoError(t, err)
+
+ // calling CheckPath before would allow git to cache part of it and successfully return later
+ require.NoError(t, os.RemoveAll(gitRepo.Path))
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ if err == nil {
+ t.Skip(
+ "git check-attr started too fast and CheckPath was successful (and likely cached)",
+ "https://codeberg.org/forgejo/forgejo/issues/2948",
+ )
+ }
+ // Depending on the order of execution, the returned error can be:
+ // - a launch error "fork/exec /usr/bin/git: no such file or directory" (when the removal happens before the Run)
+ // - a git error (stderr: "fatal: Unable to read current working directory: No such file or directory"): exit status 128 (when the removal happens after the Run)
+ // (pipe error "write |1: broken pipe" should be replaced by one of the Run errors above)
+ assert.Contains(t, err.Error(), `git check-attr`)
+ })
+
+ t.Run("Cancelled/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ var cancel context.CancelFunc
+ gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx)
+ cancel()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.ErrorIs(t, err, context.Canceled)
+ })
+
+ t.Run("Cancelled/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ var cancel context.CancelFunc
+ gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx)
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ attr, err := ac.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", attr["linguist-language"].String())
+
+ errCh := make(chan error)
+ go func() {
+ cancel()
+
+ for err == nil {
+ _, err = ac.CheckPath("i-am-a-python.p")
+ runtime.Gosched() // the cancellation must have time to propagate
+ }
+ errCh <- err
+ }()
+
+ select {
+ case <-time.After(time.Second):
+ t.Error("CheckPath did not complete within 1s")
+ case err = <-errCh:
+ require.ErrorIs(t, err, context.Canceled)
+ }
+ })
+
+ t.Run("Closed/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ require.NoError(t, ac.Close())
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.ErrorIs(t, err, fs.ErrClosed)
+ })
+
+ t.Run("Closed/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ attr, err := ac.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", attr["linguist-language"].String())
+
+ require.NoError(t, ac.Close())
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.ErrorIs(t, err, fs.ErrClosed)
+ })
+}
+
+// CopyFS is adapted from https://github.com/golang/go/issues/62484
+// which should be available with go1.23
+func CopyFS(dir string, fsys fs.FS) error {
+ return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error {
+ targ := filepath.Join(dir, filepath.FromSlash(path))
+ if d.IsDir() {
+ return os.MkdirAll(targ, 0o777)
+ }
+ r, err := fsys.Open(path)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ info, err := r.Stat()
+ if err != nil {
+ return err
+ }
+ w, err := os.OpenFile(targ, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(w, r); err != nil {
+ w.Close()
+ return fmt.Errorf("copying %s: %v", path, err)
+ }
+ return w.Close()
+ })
+}
diff --git a/modules/git/repo_base.go b/modules/git/repo_base.go
new file mode 100644
index 0000000..5f17bc1
--- /dev/null
+++ b/modules/git/repo_base.go
@@ -0,0 +1,124 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Repository represents a Git repository.
+type Repository struct {
+ Path string
+
+ tagCache *ObjectCache
+
+ gpgSettings *GPGSettings
+
+ batchInUse bool
+ batch *Batch
+
+ checkInUse bool
+ check *Batch
+
+ Ctx context.Context
+ LastCommitCache *LastCommitCache
+
+ objectFormat ObjectFormat
+}
+
+// openRepositoryWithDefaultContext opens the repository at the given path with DefaultContext.
+func openRepositoryWithDefaultContext(repoPath string) (*Repository, error) {
+ return OpenRepository(DefaultContext, repoPath)
+}
+
+// OpenRepository opens the repository at the given path with the provided context.
+func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
+ repoPath, err := filepath.Abs(repoPath)
+ if err != nil {
+ return nil, err
+ } else if !isDir(repoPath) {
+ return nil, errors.New("no such file or directory")
+ }
+
+ return &Repository{
+ Path: repoPath,
+ tagCache: newObjectCache(),
+ Ctx: ctx,
+ }, nil
+}
+
+// CatFileBatch obtains a CatFileBatch for this repository
+func (repo *Repository) CatFileBatch(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
+ if repo.batch == nil {
+ var err error
+ repo.batch, err = repo.NewBatch(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ }
+
+ if !repo.batchInUse {
+ repo.batchInUse = true
+ return repo.batch.Writer, repo.batch.Reader, func() {
+ repo.batchInUse = false
+ }, nil
+ }
+
+ log.Debug("Opening temporary cat file batch for: %s", repo.Path)
+ tempBatch, err := repo.NewBatch(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return tempBatch.Writer, tempBatch.Reader, tempBatch.Close, nil
+}
+
+// CatFileBatchCheck obtains a CatFileBatchCheck for this repository
+func (repo *Repository) CatFileBatchCheck(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
+ if repo.check == nil {
+ var err error
+ repo.check, err = repo.NewBatchCheck(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ }
+
+ if !repo.checkInUse {
+ repo.checkInUse = true
+ return repo.check.Writer, repo.check.Reader, func() {
+ repo.checkInUse = false
+ }, nil
+ }
+
+ log.Debug("Opening temporary cat file batch-check for: %s", repo.Path)
+ tempBatchCheck, err := repo.NewBatchCheck(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.Close, nil
+}
+
+func (repo *Repository) Close() error {
+ if repo == nil {
+ return nil
+ }
+ if repo.batch != nil {
+ repo.batch.Close()
+ repo.batch = nil
+ repo.batchInUse = false
+ }
+ if repo.check != nil {
+ repo.check.Close()
+ repo.check = nil
+ repo.checkInUse = false
+ }
+ repo.LastCommitCache = nil
+ repo.tagCache = nil
+ return nil
+}
diff --git a/modules/git/repo_base_test.go b/modules/git/repo_base_test.go
new file mode 100644
index 0000000..323b28f
--- /dev/null
+++ b/modules/git/repo_base_test.go
@@ -0,0 +1,163 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// This unit test relies on the implementation detail of CatFileBatch.
+func TestCatFileBatch(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ var wr WriteCloserError
+ var r *bufio.Reader
+ var cancel1 func()
+ t.Run("Request cat file batch", func(t *testing.T) {
+ assert.Nil(t, repo.batch)
+ wr, r, cancel1, err = repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.batch)
+ assert.Equal(t, repo.batch.Writer, wr)
+ assert.True(t, repo.batchInUse)
+ })
+
+ t.Run("Request temporary cat file batch", func(t *testing.T) {
+ wr, r, cancel, err := repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotEqual(t, repo.batch.Writer, wr)
+
+ t.Run("Check temporary cat file batch", func(t *testing.T) {
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "commit", typ)
+ assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha)
+ assert.EqualValues(t, 144, size)
+ })
+
+ cancel()
+ assert.True(t, repo.batchInUse)
+ })
+
+ t.Run("Check cached cat file batch", func(t *testing.T) {
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "commit", typ)
+ assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha)
+ assert.EqualValues(t, 144, size)
+ })
+
+ t.Run("Cancel cached cat file batch", func(t *testing.T) {
+ cancel1()
+ assert.False(t, repo.batchInUse)
+ assert.NotNil(t, repo.batch)
+ })
+
+ t.Run("Request cached cat file batch", func(t *testing.T) {
+ wr, _, _, err := repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.batch)
+ assert.Equal(t, repo.batch.Writer, wr)
+ assert.True(t, repo.batchInUse)
+
+ t.Run("Close git repo", func(t *testing.T) {
+ require.NoError(t, repo.Close())
+ assert.Nil(t, repo.batch)
+ })
+
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.Error(t, err)
+ })
+}
+
+// This unit test relies on the implementation detail of CatFileBatchCheck.
+func TestCatFileBatchCheck(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ var wr WriteCloserError
+ var r *bufio.Reader
+ var cancel1 func()
+ t.Run("Request cat file batch check", func(t *testing.T) {
+ assert.Nil(t, repo.check)
+ wr, r, cancel1, err = repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.check)
+ assert.Equal(t, repo.check.Writer, wr)
+ assert.True(t, repo.checkInUse)
+ })
+
+ t.Run("Request temporary cat file batch check", func(t *testing.T) {
+ wr, r, cancel, err := repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotEqual(t, repo.check.Writer, wr)
+
+ t.Run("Check temporary cat file batch check", func(t *testing.T) {
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "tag", typ)
+ assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha)
+ assert.EqualValues(t, 807, size)
+ })
+
+ cancel()
+ assert.True(t, repo.checkInUse)
+ })
+
+ t.Run("Check cached cat file batch check", func(t *testing.T) {
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "tag", typ)
+ assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha)
+ assert.EqualValues(t, 807, size)
+ })
+
+ t.Run("Cancel cached cat file batch check", func(t *testing.T) {
+ cancel1()
+ assert.False(t, repo.checkInUse)
+ assert.NotNil(t, repo.check)
+ })
+
+ t.Run("Request cached cat file batch check", func(t *testing.T) {
+ wr, _, _, err := repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.check)
+ assert.Equal(t, repo.check.Writer, wr)
+ assert.True(t, repo.checkInUse)
+
+ t.Run("Close git repo", func(t *testing.T) {
+ require.NoError(t, repo.Close())
+ assert.Nil(t, repo.check)
+ })
+
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.Error(t, err)
+ })
+}
diff --git a/modules/git/repo_blame.go b/modules/git/repo_blame.go
new file mode 100644
index 0000000..139cdd7
--- /dev/null
+++ b/modules/git/repo_blame.go
@@ -0,0 +1,23 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+)
+
+// LineBlame returns the latest commit at the given line
+func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) {
+ res, _, err := NewCommand(repo.Ctx, "blame").
+ AddOptionFormat("-L %d,%d", line, line).
+ AddOptionValues("-p", revision).
+ AddDashesAndList(file).RunStdString(&RunOpts{Dir: path})
+ if err != nil {
+ return nil, err
+ }
+ if len(res) < 40 {
+ return nil, fmt.Errorf("invalid result of blame: %s", res)
+ }
+ return repo.GetCommit(res[:40])
+}
diff --git a/modules/git/repo_blob_test.go b/modules/git/repo_blob_test.go
new file mode 100644
index 0000000..b018479
--- /dev/null
+++ b/modules/git/repo_blob_test.go
@@ -0,0 +1,70 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetBlob_Found(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCases := []struct {
+ OID string
+ Data []byte
+ }{
+ {"e2129701f1a4d54dc44f03c93bca0a2aec7c5449", []byte("file1\n")},
+ {"6c493ff740f9380390d5c9ddef4af18697ac9375", []byte("file2\n")},
+ }
+
+ for _, testCase := range testCases {
+ blob, err := r.GetBlob(testCase.OID)
+ require.NoError(t, err)
+
+ dataReader, err := blob.DataAsync()
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(dataReader)
+ require.NoError(t, dataReader.Close())
+ require.NoError(t, err)
+ assert.Equal(t, testCase.Data, data)
+ }
+}
+
+func TestRepository_GetBlob_NotExist(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCase := "0000000000000000000000000000000000000000"
+ testError := ErrNotExist{testCase, ""}
+
+ blob, err := r.GetBlob(testCase)
+ assert.Nil(t, blob)
+ assert.EqualError(t, err, testError.Error())
+}
+
+func TestRepository_GetBlob_NoId(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCase := ""
+ testError := fmt.Errorf("length %d has no matched object format: %s", len(testCase), testCase)
+
+ blob, err := r.GetBlob(testCase)
+ assert.Nil(t, blob)
+ assert.EqualError(t, err, testError.Error())
+}
diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go
new file mode 100644
index 0000000..7339c7d
--- /dev/null
+++ b/modules/git/repo_branch.go
@@ -0,0 +1,349 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// BranchPrefix base dir of the branch information file store on git
+const BranchPrefix = "refs/heads/"
+
+// IsReferenceExist returns true if given reference exists in the repository.
+func IsReferenceExist(ctx context.Context, repoPath, name string) bool {
+ _, _, err := NewCommand(ctx, "show-ref", "--verify").AddDashesAndList(name).RunStdString(&RunOpts{Dir: repoPath})
+ return err == nil
+}
+
+// IsBranchExist returns true if given branch exists in the repository.
+func IsBranchExist(ctx context.Context, repoPath, name string) bool {
+ return IsReferenceExist(ctx, repoPath, BranchPrefix+name)
+}
+
+// Branch represents a Git branch.
+type Branch struct {
+ Name string
+ Path string
+
+ gitRepo *Repository
+}
+
+// GetHEADBranch returns corresponding branch of HEAD.
+func (repo *Repository) GetHEADBranch() (*Branch, error) {
+ if repo == nil {
+ return nil, fmt.Errorf("nil repo")
+ }
+ stdout, _, err := NewCommand(repo.Ctx, "symbolic-ref", "HEAD").RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ stdout = strings.TrimSpace(stdout)
+
+ if !strings.HasPrefix(stdout, BranchPrefix) {
+ return nil, fmt.Errorf("invalid HEAD branch: %v", stdout)
+ }
+
+ return &Branch{
+ Name: stdout[len(BranchPrefix):],
+ Path: stdout,
+ gitRepo: repo,
+ }, nil
+}
+
+func GetDefaultBranch(ctx context.Context, repoPath string) (string, error) {
+ stdout, _, err := NewCommand(ctx, "symbolic-ref", "HEAD").RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return "", err
+ }
+ stdout = strings.TrimSpace(stdout)
+ if !strings.HasPrefix(stdout, BranchPrefix) {
+ return "", errors.New("the HEAD is not a branch: " + stdout)
+ }
+ return strings.TrimPrefix(stdout, BranchPrefix), nil
+}
+
+// GetBranch returns a branch by it's name
+func (repo *Repository) GetBranch(branch string) (*Branch, error) {
+ if !repo.IsBranchExist(branch) {
+ return nil, ErrBranchNotExist{branch}
+ }
+ return &Branch{
+ Path: repo.Path,
+ Name: branch,
+ gitRepo: repo,
+ }, nil
+}
+
+// GetBranches returns a slice of *git.Branch
+func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) {
+ brs, countAll, err := repo.GetBranchNames(skip, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ branches := make([]*Branch, len(brs))
+ for i := range brs {
+ branches[i] = &Branch{
+ Path: repo.Path,
+ Name: brs[i],
+ gitRepo: repo,
+ }
+ }
+
+ return branches, countAll, nil
+}
+
+// DeleteBranchOptions Option(s) for delete branch
+type DeleteBranchOptions struct {
+ Force bool
+}
+
+// DeleteBranch delete a branch by name on repository.
+func (repo *Repository) DeleteBranch(name string, opts DeleteBranchOptions) error {
+ cmd := NewCommand(repo.Ctx, "branch")
+
+ if opts.Force {
+ cmd.AddArguments("-D")
+ } else {
+ cmd.AddArguments("-d")
+ }
+
+ cmd.AddDashesAndList(name)
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+
+ return err
+}
+
+// CreateBranch create a new branch
+func (repo *Repository) CreateBranch(branch, oldbranchOrCommit string) error {
+ cmd := NewCommand(repo.Ctx, "branch")
+ cmd.AddDashesAndList(branch, oldbranchOrCommit)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+
+ return err
+}
+
+// AddRemote adds a new remote to repository.
+func (repo *Repository) AddRemote(name, url string, fetch bool) error {
+ cmd := NewCommand(repo.Ctx, "remote", "add")
+ if fetch {
+ cmd.AddArguments("-f")
+ }
+ cmd.AddDynamicArguments(name, url)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// RemoveRemote removes a remote from repository.
+func (repo *Repository) RemoveRemote(name string) error {
+ _, _, err := NewCommand(repo.Ctx, "remote", "rm").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// GetCommit returns the head commit of a branch
+func (branch *Branch) GetCommit() (*Commit, error) {
+ return branch.gitRepo.GetBranchCommit(branch.Name)
+}
+
+// RenameBranch rename a branch
+func (repo *Repository) RenameBranch(from, to string) error {
+ _, _, err := NewCommand(repo.Ctx, "branch", "-m").AddDynamicArguments(from, to).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// IsObjectExist returns true if given reference exists in the repository.
+func (repo *Repository) IsObjectExist(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ sha, _, _, err := ReadBatchLine(rd)
+ return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name)))
+}
+
+// IsReferenceExist returns true if given reference exists in the repository.
+func (repo *Repository) IsReferenceExist(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ _, _, _, err = ReadBatchLine(rd)
+ return err == nil
+}
+
+// IsBranchExist returns true if given branch exists in current repository.
+func (repo *Repository) IsBranchExist(name string) bool {
+ if repo == nil || name == "" {
+ return false
+ }
+
+ return repo.IsReferenceExist(BranchPrefix + name)
+}
+
+// GetBranchNames returns branches from the repository, skipping "skip" initial branches and
+// returning at most "limit" branches, or all branches if "limit" is 0.
+func (repo *Repository) GetBranchNames(skip, limit int) ([]string, int, error) {
+ return callShowRef(repo.Ctx, repo.Path, BranchPrefix, TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}, skip, limit)
+}
+
+// WalkReferences walks all the references from the repository
+// refType should be empty, ObjectTag or ObjectBranch. All other values are equivalent to empty.
+func (repo *Repository) WalkReferences(refType ObjectType, skip, limit int, walkfn func(sha1, refname string) error) (int, error) {
+ var args TrustedCmdArgs
+ switch refType {
+ case ObjectTag:
+ args = TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}
+ case ObjectBranch:
+ args = TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}
+ }
+
+ return WalkShowRef(repo.Ctx, repo.Path, args, skip, limit, walkfn)
+}
+
+// callShowRef return refs, if limit = 0 it will not limit
+func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs TrustedCmdArgs, skip, limit int) (branchNames []string, countAll int, err error) {
+ countAll, err = WalkShowRef(ctx, repoPath, extraArgs, skip, limit, func(_, branchName string) error {
+ branchName = strings.TrimPrefix(branchName, trimPrefix)
+ branchNames = append(branchNames, branchName)
+
+ return nil
+ })
+ return branchNames, countAll, err
+}
+
+func WalkShowRef(ctx context.Context, repoPath string, extraArgs TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) {
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ go func() {
+ stderrBuilder := &strings.Builder{}
+ args := TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"}
+ args = append(args, extraArgs...)
+ err := NewCommand(ctx, args...).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: stdoutWriter,
+ Stderr: stderrBuilder,
+ })
+ if err != nil {
+ if stderrBuilder.Len() == 0 {
+ _ = stdoutWriter.Close()
+ return
+ }
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderrBuilder.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ i := 0
+ bufReader := bufio.NewReader(stdoutReader)
+ for i < skip {
+ _, isPrefix, err := bufReader.ReadLine()
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if !isPrefix {
+ i++
+ }
+ }
+ for limit == 0 || i < skip+limit {
+ // The output of show-ref is simply a list:
+ // <sha> SP <ref> LF
+ sha, err := bufReader.ReadString(' ')
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+
+ branchName, err := bufReader.ReadString('\n')
+ if err == io.EOF {
+ // This shouldn't happen... but we'll tolerate it for the sake of peace
+ return i, nil
+ }
+ if err != nil {
+ return i, err
+ }
+
+ if len(branchName) > 0 {
+ branchName = branchName[:len(branchName)-1]
+ }
+
+ if len(sha) > 0 {
+ sha = sha[:len(sha)-1]
+ }
+
+ err = walkfn(sha, branchName)
+ if err != nil {
+ return i, err
+ }
+ i++
+ }
+ // count all refs
+ for limit != 0 {
+ _, isPrefix, err := bufReader.ReadLine()
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if !isPrefix {
+ i++
+ }
+ }
+ return i, nil
+}
+
+// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash
+func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) {
+ var revList []string
+ _, err := WalkShowRef(repo.Ctx, repo.Path, nil, 0, 0, func(walkSha, refname string) error {
+ if walkSha == sha && strings.HasPrefix(refname, prefix) {
+ revList = append(revList, refname)
+ }
+ return nil
+ })
+ return revList, err
+}
diff --git a/modules/git/repo_branch_test.go b/modules/git/repo_branch_test.go
new file mode 100644
index 0000000..610c845
--- /dev/null
+++ b/modules/git/repo_branch_test.go
@@ -0,0 +1,197 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetBranches(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ branches, countAll, err := bareRepo1.GetBranchNames(0, 2)
+
+ require.NoError(t, err)
+ assert.Len(t, branches, 2)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{"master", "branch2"}, branches)
+
+ branches, countAll, err = bareRepo1.GetBranchNames(0, 0)
+
+ require.NoError(t, err)
+ assert.Len(t, branches, 3)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{"master", "branch2", "branch1"}, branches)
+
+ branches, countAll, err = bareRepo1.GetBranchNames(5, 1)
+
+ require.NoError(t, err)
+ assert.Empty(t, branches)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{}, branches)
+}
+
+func BenchmarkRepository_GetBranches(b *testing.B) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer bareRepo1.Close()
+
+ for i := 0; i < b.N; i++ {
+ _, _, err := bareRepo1.GetBranchNames(0, 0)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func TestGetRefsBySha(t *testing.T) {
+ bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
+ bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer bareRepo5.Close()
+
+ // do not exist
+ branches, err := bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
+ require.NoError(t, err)
+ assert.Empty(t, branches)
+
+ // refs/pull/1/head
+ branches, err = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", PullPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/pull/1/head"}, branches)
+
+ branches, err = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", BranchPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/heads/master", "refs/heads/master-clone"}, branches)
+
+ branches, err = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", BranchPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/heads/test-patch-1"}, branches)
+}
+
+func BenchmarkGetRefsBySha(b *testing.B) {
+ bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
+ bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer bareRepo5.Close()
+
+ _, _ = bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
+ _, _ = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", "")
+ _, _ = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", "")
+ _, _ = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", "")
+}
+
+func TestRepository_IsObjectExist(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ supportShortHash := true
+
+ tests := []struct {
+ name string
+ arg string
+ want bool
+ }{
+ {
+ name: "empty",
+ arg: "",
+ want: false,
+ },
+ {
+ name: "branch",
+ arg: "master",
+ want: false,
+ },
+ {
+ name: "commit hash",
+ arg: "ce064814f4a0d337b333e646ece456cd39fab612",
+ want: true,
+ },
+ {
+ name: "short commit hash",
+ arg: "ce06481",
+ want: supportShortHash,
+ },
+ {
+ name: "blob hash",
+ arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
+ want: true,
+ },
+ {
+ name: "short blob hash",
+ arg: "153f451",
+ want: supportShortHash,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, repo.IsObjectExist(tt.arg))
+ })
+ }
+}
+
+func TestRepository_IsReferenceExist(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ supportBlobHash := true
+
+ tests := []struct {
+ name string
+ arg string
+ want bool
+ }{
+ {
+ name: "empty",
+ arg: "",
+ want: false,
+ },
+ {
+ name: "branch",
+ arg: "master",
+ want: true,
+ },
+ {
+ name: "commit hash",
+ arg: "ce064814f4a0d337b333e646ece456cd39fab612",
+ want: true,
+ },
+ {
+ name: "short commit hash",
+ arg: "ce06481",
+ want: true,
+ },
+ {
+ name: "blob hash",
+ arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
+ want: supportBlobHash,
+ },
+ {
+ name: "short blob hash",
+ arg: "153f451",
+ want: supportBlobHash,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, repo.IsReferenceExist(tt.arg))
+ })
+ }
+}
diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go
new file mode 100644
index 0000000..1f3d64f
--- /dev/null
+++ b/modules/git/repo_commit.go
@@ -0,0 +1,677 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "io"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// GetBranchCommitID returns last commit ID string of given branch.
+func (repo *Repository) GetBranchCommitID(name string) (string, error) {
+ return repo.GetRefCommitID(BranchPrefix + name)
+}
+
+// GetTagCommitID returns last commit ID string of given tag.
+func (repo *Repository) GetTagCommitID(name string) (string, error) {
+ return repo.GetRefCommitID(TagPrefix + name)
+}
+
+// GetCommit returns commit object of by ID string.
+func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
+ id, err := repo.ConvertToGitID(commitID)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getCommit(id)
+}
+
+// GetBranchCommit returns the last commit of given branch.
+func (repo *Repository) GetBranchCommit(name string) (*Commit, error) {
+ commitID, err := repo.GetBranchCommitID(name)
+ if err != nil {
+ return nil, err
+ }
+ return repo.GetCommit(commitID)
+}
+
+// GetTagCommit get the commit of the specific tag via name
+func (repo *Repository) GetTagCommit(name string) (*Commit, error) {
+ commitID, err := repo.GetTagCommitID(name)
+ if err != nil {
+ return nil, err
+ }
+ return repo.GetCommit(commitID)
+}
+
+func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Commit, error) {
+ // File name starts with ':' must be escaped.
+ if relpath[0] == ':' {
+ relpath = `\` + relpath
+ }
+
+ stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDynamicArguments(id.String()).AddDashesAndList(relpath).RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ id, err := NewIDFromString(stdout)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getCommit(id)
+}
+
+// GetCommitByPath returns the last commit of relative path.
+func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
+ stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDashesAndList(relpath).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ commits, err := repo.parsePrettyFormatLogToList(stdout)
+ if err != nil {
+ return nil, err
+ }
+ if len(commits) == 0 {
+ return nil, ErrNotExist{ID: relpath}
+ }
+ return commits[0], nil
+}
+
+func (repo *Repository) commitsByRange(id ObjectID, page, pageSize int, not string) ([]*Commit, error) {
+ cmd := NewCommand(repo.Ctx, "log").
+ AddOptionFormat("--skip=%d", (page-1)*pageSize).
+ AddOptionFormat("--max-count=%d", pageSize).
+ AddArguments(prettyLogFormat).
+ AddDynamicArguments(id.String())
+
+ if not != "" {
+ cmd.AddOptionValues("--not", not)
+ }
+
+ stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.parsePrettyFormatLogToList(stdout)
+}
+
+func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([]*Commit, error) {
+ // add common arguments to git command
+ addCommonSearchArgs := func(c *Command) {
+ // ignore case
+ c.AddArguments("-i")
+
+ // add authors if present in search query
+ for _, v := range opts.Authors {
+ c.AddOptionFormat("--author=%s", v)
+ }
+
+ // add committers if present in search query
+ for _, v := range opts.Committers {
+ c.AddOptionFormat("--committer=%s", v)
+ }
+
+ // add time constraints if present in search query
+ if len(opts.After) > 0 {
+ c.AddOptionFormat("--after=%s", opts.After)
+ }
+ if len(opts.Before) > 0 {
+ c.AddOptionFormat("--before=%s", opts.Before)
+ }
+ }
+
+ // create new git log command with limit of 100 commits
+ cmd := NewCommand(repo.Ctx, "log", "-100", prettyLogFormat).AddDynamicArguments(id.String())
+
+ // pretend that all refs along with HEAD were listed on command line as <commis>
+ // https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
+ // note this is done only for command created above
+ if opts.All {
+ cmd.AddArguments("--all")
+ }
+
+ // interpret search string keywords as string instead of regex
+ cmd.AddArguments("--fixed-strings")
+
+ // add remaining keywords from search string
+ // note this is done only for command created above
+ for _, v := range opts.Keywords {
+ cmd.AddOptionFormat("--grep=%s", v)
+ }
+
+ // search for commits matching given constraints and keywords in commit msg
+ addCommonSearchArgs(cmd)
+ stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ if len(stdout) != 0 {
+ stdout = append(stdout, '\n')
+ }
+
+ // if there are any keywords (ie not committer:, author:, time:)
+ // then let's iterate over them
+ for _, v := range opts.Keywords {
+ // ignore anything not matching a valid sha pattern
+ if id.Type().IsValid(v) {
+ // create new git log command with 1 commit limit
+ hashCmd := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat)
+ // add previous arguments except for --grep and --all
+ addCommonSearchArgs(hashCmd)
+ // add keyword as <commit>
+ hashCmd.AddDynamicArguments(v)
+
+ // search with given constraints for commit matching sha hash of v
+ hashMatching, _, err := hashCmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil || bytes.Contains(stdout, hashMatching) {
+ continue
+ }
+ stdout = append(stdout, hashMatching...)
+ stdout = append(stdout, '\n')
+ }
+ }
+
+ return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'}))
+}
+
+// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
+// You must ensure that id1 and id2 are valid commit ids.
+func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only", "-z").AddDynamicArguments(id1, id2).AddDashesAndList(filename).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return false, err
+ }
+ return len(strings.TrimSpace(string(stdout))) > 0, nil
+}
+
+// FileCommitsCount return the number of files at a revision
+func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) {
+ return CommitsCount(repo.Ctx,
+ CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{revision},
+ RelPath: []string{file},
+ })
+}
+
+type CommitsByFileAndRangeOptions struct {
+ Revision string
+ File string
+ Not string
+ Page int
+}
+
+// CommitsByFileAndRange return the commits according revision file and the page
+func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
+ skip := (opts.Page - 1) * setting.Git.CommitsRangeSize
+
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+ go func() {
+ stderr := strings.Builder{}
+ gitCmd := NewCommand(repo.Ctx, "rev-list").
+ AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize*opts.Page).
+ AddOptionFormat("--skip=%d", skip)
+ gitCmd.AddDynamicArguments(opts.Revision)
+
+ if opts.Not != "" {
+ gitCmd.AddOptionValues("--not", opts.Not)
+ }
+
+ gitCmd.AddDashesAndList(opts.File)
+ err := gitCmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+
+ length := objectFormat.FullLength()
+ commits := []*Commit{}
+ shaline := make([]byte, length+1)
+ for {
+ n, err := io.ReadFull(stdoutReader, shaline)
+ if err != nil || n < length {
+ if err == io.EOF {
+ err = nil
+ }
+ return commits, err
+ }
+ objectID, err := NewIDFromString(string(shaline[0:length]))
+ if err != nil {
+ return nil, err
+ }
+ commit, err := repo.getCommit(objectID)
+ if err != nil {
+ return nil, err
+ }
+ commits = append(commits, commit)
+ }
+}
+
+// FilesCountBetween return the number of files changed between two commits
+func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID + "..." + endCommitID).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated.
+ // previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID, endCommitID).RunStdString(&RunOpts{Dir: repo.Path})
+ }
+ if err != nil {
+ return 0, err
+ }
+ return len(strings.Split(stdout, "\n")) - 1, nil
+}
+
+// CommitsBetween returns a list that contains commits between [before, last).
+// If before is detached (removed by reset + push) it is not included.
+func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [before, last)
+func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip int) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list --max-count n before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenNotBase returns a list that contains commits between [before, last), excluding commits in baseBranch.
+// If before is detached (removed by reset + push) it is not included.
+func (repo *Repository) CommitsBetweenNotBase(last, before *Commit, baseBranch string) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenIDs return commits between twoe commits
+func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error) {
+ lastCommit, err := repo.GetCommit(last)
+ if err != nil {
+ return nil, err
+ }
+ if before == "" {
+ return repo.CommitsBetween(lastCommit, nil)
+ }
+ beforeCommit, err := repo.GetCommit(before)
+ if err != nil {
+ return nil, err
+ }
+ return repo.CommitsBetween(lastCommit, beforeCommit)
+}
+
+// CommitsCountBetween return numbers of commits between two commits
+func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) {
+ count, err := CommitsCount(repo.Ctx, CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{start + ".." + end},
+ })
+
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ return CommitsCount(repo.Ctx, CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{start, end},
+ })
+ }
+
+ return count, err
+}
+
+// commitsBefore the limit is depth, not total number of returned commits.
+func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) {
+ cmd := NewCommand(repo.Ctx, "log", prettyLogFormat)
+ if limit > 0 {
+ cmd.AddOptionFormat("-%d", limit)
+ }
+ cmd.AddDynamicArguments(id.String())
+
+ stdout, _, runErr := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+ if err != nil {
+ return nil, err
+ }
+
+ commits := make([]*Commit, 0, len(formattedLog))
+ for _, commit := range formattedLog {
+ branches, err := repo.getBranches(commit, 2)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(branches) > 1 {
+ break
+ }
+
+ commits = append(commits, commit)
+ }
+
+ return commits, nil
+}
+
+func (repo *Repository) getCommitsBefore(id ObjectID) ([]*Commit, error) {
+ return repo.commitsBefore(id, 0)
+}
+
+func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, error) {
+ return repo.commitsBefore(id, num)
+}
+
+func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) {
+ if CheckGitVersionAtLeast("2.7.0") == nil {
+ stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)").
+ AddOptionFormat("--count=%d", limit).
+ AddOptionValues("--contains", commit.ID.String(), BranchPrefix).
+ RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ branches := strings.Fields(stdout)
+ return branches, nil
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "branch").AddOptionValues("--contains", commit.ID.String()).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ refs := strings.Split(stdout, "\n")
+
+ var max int
+ if len(refs) > limit {
+ max = limit
+ } else {
+ max = len(refs) - 1
+ }
+
+ branches := make([]string, max)
+ for i, ref := range refs[:max] {
+ parts := strings.Fields(ref)
+
+ branches[i] = parts[len(parts)-1]
+ }
+ return branches, nil
+}
+
+// GetCommitsFromIDs get commits from commit IDs
+func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit {
+ commits := make([]*Commit, 0, len(commitIDs))
+
+ for _, commitID := range commitIDs {
+ commit, err := repo.GetCommit(commitID)
+ if err == nil && commit != nil {
+ commits = append(commits, commit)
+ }
+ }
+
+ return commits
+}
+
+// IsCommitInBranch check if the commit is on the branch
+func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
+ stdout, _, err := NewCommand(repo.Ctx, "branch", "--contains").AddDynamicArguments(commitID, branch).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return false, err
+ }
+ return len(stdout) > 0, err
+}
+
+func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error {
+ if repo.LastCommitCache == nil {
+ commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) {
+ commit, err := repo.GetCommit(sha)
+ if err != nil {
+ return 0, err
+ }
+ return commit.CommitsCount()
+ })
+ if err != nil {
+ return err
+ }
+ repo.LastCommitCache = NewLastCommitCache(commitsCount, fullName, repo, cache.GetCache())
+ }
+ return nil
+}
+
+// ResolveReference resolves a name to a reference
+func (repo *Repository) ResolveReference(name string) (string, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--hash").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ if strings.Contains(err.Error(), "not a valid ref") {
+ return "", ErrNotExist{name, ""}
+ }
+ return "", err
+ }
+ stdout = strings.TrimSpace(stdout)
+ if stdout == "" {
+ return "", ErrNotExist{name, ""}
+ }
+
+ return stdout, nil
+}
+
+// GetRefCommitID returns the last commit ID string of given reference (branch or tag).
+func (repo *Repository) GetRefCommitID(name string) (string, error) {
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return "", err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ return "", err
+ }
+ shaBs, _, _, err := ReadBatchLine(rd)
+ if IsErrNotExist(err) {
+ return "", ErrNotExist{name, ""}
+ }
+
+ return string(shaBs), nil
+}
+
+// SetReference sets the commit ID string of given reference (e.g. branch or tag).
+func (repo *Repository) SetReference(name, commitID string) error {
+ _, _, err := NewCommand(repo.Ctx, "update-ref").AddDynamicArguments(name, commitID).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// RemoveReference removes the given reference (e.g. branch or tag).
+func (repo *Repository) RemoveReference(name string) error {
+ _, _, err := NewCommand(repo.Ctx, "update-ref", "--no-deref", "-d").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// IsCommitExist returns true if given commit exists in current repository.
+func (repo *Repository) IsCommitExist(name string) bool {
+ if err := ensureValidGitRepository(repo.Ctx, repo.Path); err != nil {
+ log.Error("IsCommitExist: %v", err)
+ return false
+ }
+ _, _, err := NewCommand(repo.Ctx, "cat-file", "-e").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err == nil
+}
+
+func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(id.String() + "\n"))
+
+ return repo.getCommitFromBatchReader(rd, id)
+}
+
+func (repo *Repository) getCommitFromBatchReader(rd *bufio.Reader, id ObjectID) (*Commit, error) {
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ if errors.Is(err, io.EOF) || IsErrNotExist(err) {
+ return nil, ErrNotExist{ID: id.String()}
+ }
+ return nil, err
+ }
+
+ switch typ {
+ case "missing":
+ return nil, ErrNotExist{ID: id.String()}
+ case "tag":
+ // then we need to parse the tag
+ // and load the commit
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+ tag, err := parseTagData(id.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+
+ commit, err := tag.Commit(repo)
+ if err != nil {
+ return nil, err
+ }
+
+ return commit, nil
+ case "commit":
+ commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+
+ return commit, nil
+ default:
+ log.Debug("Unknown typ: %s", typ)
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{
+ ID: id.String(),
+ }
+ }
+}
+
+// ConvertToGitID returns a GitHash object from a potential ID string
+func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) {
+ ID, err := NewIDFromString(commitID)
+ if err == nil {
+ return ID, nil
+ }
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(commitID + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ sha, _, _, err := ReadBatchLine(rd)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return nil, ErrNotExist{commitID, ""}
+ }
+ return nil, err
+ }
+
+ return MustIDFromString(string(sha)), nil
+}
diff --git a/modules/git/repo_commit_test.go b/modules/git/repo_commit_test.go
new file mode 100644
index 0000000..e2a9f97
--- /dev/null
+++ b/modules/git/repo_commit_test.go
@@ -0,0 +1,103 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetCommitBranches(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ // these test case are specific to the repo1_bare test repo
+ testCases := []struct {
+ CommitID string
+ ExpectedBranches []string
+ }{
+ {"2839944139e0de9737a044f78b0e4b40d989a9e3", []string{"branch1"}},
+ {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", []string{"branch2"}},
+ {"37991dec2c8e592043f47155ce4808d4580f9123", []string{"master"}},
+ {"95bb4d39648ee7e325106df01a621c530863a653", []string{"branch1", "branch2"}},
+ {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", []string{"branch2", "master"}},
+ {"master", []string{"master"}},
+ }
+ for _, testCase := range testCases {
+ commit, err := bareRepo1.GetCommit(testCase.CommitID)
+ require.NoError(t, err)
+ branches, err := bareRepo1.getBranches(commit, 2)
+ require.NoError(t, err)
+ assert.Equal(t, testCase.ExpectedBranches, branches)
+ }
+}
+
+func TestGetTagCommitWithSignature(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ // both the tag and the commit are signed here, this validates only the commit signature
+ commit, err := bareRepo1.GetCommit("28b55526e7100924d864dd89e35c1ea62e7a5a32")
+ require.NoError(t, err)
+ assert.NotNil(t, commit)
+ assert.NotNil(t, commit.Signature)
+ // test that signature is not in message
+ assert.Equal(t, "signed-commit\n", commit.CommitMessage)
+}
+
+func TestGetCommitWithBadCommitID(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ commit, err := bareRepo1.GetCommit("bad_branch")
+ assert.Nil(t, commit)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+}
+
+func TestIsCommitInBranch(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ result, err := bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch1")
+ require.NoError(t, err)
+ assert.True(t, result)
+
+ result, err = bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
+ require.NoError(t, err)
+ assert.False(t, result)
+}
+
+func TestRepository_CommitsBetweenIDs(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo4_commitsbetween")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ cases := []struct {
+ OldID string
+ NewID string
+ ExpectedCommits int
+ }{
+ {"fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", "78a445db1eac62fe15e624e1137965969addf344", 1}, // com1 -> com2
+ {"78a445db1eac62fe15e624e1137965969addf344", "fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", 0}, // reset HEAD~, com2 -> com1
+ {"78a445db1eac62fe15e624e1137965969addf344", "a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca", 1}, // com2 -> com2_new
+ }
+ for i, c := range cases {
+ commits, err := bareRepo1.CommitsBetweenIDs(c.NewID, c.OldID)
+ require.NoError(t, err)
+ assert.Len(t, commits, c.ExpectedCommits, "case %d", i)
+ }
+}
diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go
new file mode 100644
index 0000000..492438b
--- /dev/null
+++ b/modules/git/repo_commitgraph.go
@@ -0,0 +1,20 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+)
+
+// WriteCommitGraph write commit graph to speed up repo access
+// this requires git v2.18 to be installed
+func WriteCommitGraph(ctx context.Context, repoPath string) error {
+ if CheckGitVersionAtLeast("2.18") == nil {
+ if _, _, err := NewCommand(ctx, "commit-graph", "write").RunStdString(&RunOpts{Dir: repoPath}); err != nil {
+ return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err)
+ }
+ }
+ return nil
+}
diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go
new file mode 100644
index 0000000..b6e9d2b
--- /dev/null
+++ b/modules/git/repo_compare.go
@@ -0,0 +1,345 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ logger "code.gitea.io/gitea/modules/log"
+)
+
+// CompareInfo represents needed information for comparing references.
+type CompareInfo struct {
+ MergeBase string
+ BaseCommitID string
+ HeadCommitID string
+ Commits []*Commit
+ NumFiles int
+}
+
+// GetMergeBase checks and returns merge base of two branches and the reference used as base.
+func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, string, error) {
+ if tmpRemote == "" {
+ tmpRemote = "origin"
+ }
+
+ if tmpRemote != "origin" {
+ tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base
+ // Fetch commit into a temporary branch in order to be able to handle commits and tags
+ _, _, err := NewCommand(repo.Ctx, "fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base + ":" + tmpBaseName).RunStdString(&RunOpts{Dir: repo.Path})
+ if err == nil {
+ base = tmpBaseName
+ }
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "merge-base").AddDashesAndList(base, head).RunStdString(&RunOpts{Dir: repo.Path})
+ return strings.TrimSpace(stdout), base, err
+}
+
+// GetCompareInfo generates and returns compare information between base and head branches of repositories.
+func (repo *Repository) GetCompareInfo(basePath, baseBranch, headBranch string, directComparison, fileOnly bool) (_ *CompareInfo, err error) {
+ var (
+ remoteBranch string
+ tmpRemote string
+ )
+
+ // We don't need a temporary remote for same repository.
+ if repo.Path != basePath {
+ // Add a temporary remote
+ tmpRemote = strconv.FormatInt(time.Now().UnixNano(), 10)
+ if err = repo.AddRemote(tmpRemote, basePath, false); err != nil {
+ return nil, fmt.Errorf("AddRemote: %w", err)
+ }
+ defer func() {
+ if err := repo.RemoveRemote(tmpRemote); err != nil {
+ logger.Error("GetPullRequestInfo: RemoveRemote: %v", err)
+ }
+ }()
+ }
+
+ compareInfo := new(CompareInfo)
+
+ compareInfo.HeadCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, headBranch)
+ if err != nil {
+ compareInfo.HeadCommitID = headBranch
+ }
+
+ compareInfo.MergeBase, remoteBranch, err = repo.GetMergeBase(tmpRemote, baseBranch, headBranch)
+ if err == nil {
+ compareInfo.BaseCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch)
+ if err != nil {
+ compareInfo.BaseCommitID = remoteBranch
+ }
+ separator := "..."
+ baseCommitID := compareInfo.MergeBase
+ if directComparison {
+ separator = ".."
+ baseCommitID = compareInfo.BaseCommitID
+ }
+
+ // We have a common base - therefore we know that ... should work
+ if !fileOnly {
+ // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
+ var logs []byte
+ logs, _, err = NewCommand(repo.Ctx, "log").AddArguments(prettyLogFormat).
+ AddDynamicArguments(baseCommitID + separator + headBranch).AddArguments("--").
+ RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ compareInfo.Commits, err = repo.parsePrettyFormatLogToList(logs)
+ if err != nil {
+ return nil, fmt.Errorf("parsePrettyFormatLogToList: %w", err)
+ }
+ } else {
+ compareInfo.Commits = []*Commit{}
+ }
+ } else {
+ compareInfo.Commits = []*Commit{}
+ compareInfo.MergeBase, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch)
+ if err != nil {
+ compareInfo.MergeBase = remoteBranch
+ }
+ compareInfo.BaseCommitID = compareInfo.MergeBase
+ }
+
+ // Count number of changed files.
+ // This probably should be removed as we need to use shortstat elsewhere
+ // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly
+ compareInfo.NumFiles, err = repo.GetDiffNumChangedFiles(remoteBranch, headBranch, directComparison)
+ if err != nil {
+ return nil, err
+ }
+ return compareInfo, nil
+}
+
+type lineCountWriter struct {
+ numLines int
+}
+
+// Write counts the number of newlines in the provided bytestream
+func (l *lineCountWriter) Write(p []byte) (n int, err error) {
+ n = len(p)
+ l.numLines += bytes.Count(p, []byte{'\000'})
+ return n, err
+}
+
+// GetDiffNumChangedFiles counts the number of changed files
+// This is substantially quicker than shortstat but...
+func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparison bool) (int, error) {
+ // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly
+ w := &lineCountWriter{}
+ stderr := new(bytes.Buffer)
+
+ separator := "..."
+ if directComparison {
+ separator = ".."
+ }
+
+ // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
+ if err := NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base + separator + head).AddArguments("--").
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ }); err != nil {
+ if strings.Contains(stderr.String(), "no merge base") {
+ // git >= 2.28 now returns an error if base and head have become unrelated.
+ // previously it would return the results of git diff -z --name-only base head so let's try that...
+ w = &lineCountWriter{}
+ stderr.Reset()
+ if err = NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ }); err == nil {
+ return w.numLines, nil
+ }
+ }
+ return 0, fmt.Errorf("%w: Stderr: %s", err, stderr)
+ }
+ return w.numLines, nil
+}
+
+// GetDiffShortStat counts number of changed files, number of additions and deletions
+func (repo *Repository) GetDiffShortStat(base, head string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ numFiles, totalAdditions, totalDeletions, err = GetDiffShortStat(repo.Ctx, repo.Path, nil, base+"..."+head)
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ return GetDiffShortStat(repo.Ctx, repo.Path, nil, base, head)
+ }
+ return numFiles, totalAdditions, totalDeletions, err
+}
+
+// GetDiffShortStat counts number of changed files, number of additions and deletions
+func GetDiffShortStat(ctx context.Context, repoPath string, trustedArgs TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ // Now if we call:
+ // $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875
+ // we get:
+ // " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n"
+ cmd := NewCommand(ctx, "diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...)
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return 0, 0, 0, err
+ }
+
+ return parseDiffStat(stdout)
+}
+
+var shortStatFormat = regexp.MustCompile(
+ `\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`)
+
+var patchCommits = regexp.MustCompile(`^From\s(\w+)\s`)
+
+func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ if len(stdout) == 0 || stdout == "\n" {
+ return 0, 0, 0, nil
+ }
+ groups := shortStatFormat.FindStringSubmatch(stdout)
+ if len(groups) != 4 {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s groups: %s", stdout, groups)
+ }
+
+ numFiles, err = strconv.Atoi(groups[1])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumFiles %w", stdout, err)
+ }
+
+ if len(groups[2]) != 0 {
+ totalAdditions, err = strconv.Atoi(groups[2])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumAdditions %w", stdout, err)
+ }
+ }
+
+ if len(groups[3]) != 0 {
+ totalDeletions, err = strconv.Atoi(groups[3])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumDeletions %w", stdout, err)
+ }
+ }
+ return numFiles, totalAdditions, totalDeletions, err
+}
+
+// GetDiffOrPatch generates either diff or formatted patch data between given revisions
+func (repo *Repository) GetDiffOrPatch(base, head string, w io.Writer, patch, binary bool) error {
+ if patch {
+ return repo.GetPatch(base, head, w)
+ }
+ if binary {
+ return repo.GetDiffBinary(base, head, w)
+ }
+ return repo.GetDiff(base, head, w)
+}
+
+// GetDiff generates and returns patch data between given revisions, optimized for human readability
+func (repo *Repository) GetDiff(base, head string, w io.Writer) error {
+ return NewCommand(repo.Ctx, "diff", "-p").AddDynamicArguments(base, head).Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+}
+
+// GetDiffBinary generates and returns patch data between given revisions, including binary diffs.
+func (repo *Repository) GetDiffBinary(base, head string, w io.Writer) error {
+ return NewCommand(repo.Ctx, "diff", "-p", "--binary", "--histogram").AddDynamicArguments(base, head).Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+}
+
+// GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply`
+func (repo *Repository) GetPatch(base, head string, w io.Writer) error {
+ stderr := new(bytes.Buffer)
+ err := NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base + "..." + head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) {
+ return NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base, head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+ }
+ return err
+}
+
+// GetFilesChangedBetween returns a list of all files that have been changed between the given commits
+// If base is undefined empty SHA (zeros), it only returns the files changed in the head commit
+// If base is the SHA of an empty tree (EmptyTreeSHA), it returns the files changes from the initial commit to the head commit
+func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ cmd := NewCommand(repo.Ctx, "diff-tree", "--name-only", "--root", "--no-commit-id", "-r", "-z")
+ if base == objectFormat.EmptyObjectID().String() {
+ cmd.AddDynamicArguments(head)
+ } else {
+ cmd.AddDynamicArguments(base, head)
+ }
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ split := strings.Split(stdout, "\000")
+
+ // Because Git will always emit filenames with a terminal NUL ignore the last entry in the split - which will always be empty.
+ if len(split) > 0 {
+ split = split[:len(split)-1]
+ }
+
+ return split, err
+}
+
+// GetDiffFromMergeBase generates and return patch data from merge base to head
+func (repo *Repository) GetDiffFromMergeBase(base, head string, w io.Writer) error {
+ stderr := new(bytes.Buffer)
+ err := NewCommand(repo.Ctx, "diff", "-p", "--binary").AddDynamicArguments(base + "..." + head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) {
+ return repo.GetDiffBinary(base, head, w)
+ }
+ return err
+}
+
+// ReadPatchCommit will check if a diff patch exists and return stats
+func (repo *Repository) ReadPatchCommit(prID int64) (commitSHA string, err error) {
+ // Migrated repositories download patches to "pulls" location
+ patchFile := fmt.Sprintf("pulls/%d.patch", prID)
+ loadPatch, err := os.Open(filepath.Join(repo.Path, patchFile))
+ if err != nil {
+ return "", err
+ }
+ defer loadPatch.Close()
+ // Read only the first line of the patch - usually it contains the first commit made in patch
+ scanner := bufio.NewScanner(loadPatch)
+ scanner.Scan()
+ // Parse the Patch stats, sometimes Migration returns a 404 for the patch file
+ commitSHAGroups := patchCommits.FindStringSubmatch(scanner.Text())
+ if len(commitSHAGroups) != 0 {
+ commitSHA = commitSHAGroups[1]
+ } else {
+ return "", errors.New("patch file doesn't contain valid commit ID")
+ }
+ return commitSHA, nil
+}
diff --git a/modules/git/repo_compare_test.go b/modules/git/repo_compare_test.go
new file mode 100644
index 0000000..86bd685
--- /dev/null
+++ b/modules/git/repo_compare_test.go
@@ -0,0 +1,164 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetFormatPatch(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ repo, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+
+ rd := &bytes.Buffer{}
+ err = repo.GetPatch("8d92fc95^", "8d92fc95", rd)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ patchb, err := io.ReadAll(rd)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ patch := string(patchb)
+ assert.Regexp(t, "^From 8d92fc95", patch)
+ assert.Contains(t, patch, "Subject: [PATCH] Add file2.txt")
+}
+
+func TestReadPatch(t *testing.T) {
+ // Ensure we can read the patch files
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+ // This patch doesn't exist
+ noFile, err := repo.ReadPatchCommit(0)
+ require.Error(t, err)
+
+ // This patch is an empty one (sometimes it's a 404)
+ noCommit, err := repo.ReadPatchCommit(1)
+ require.Error(t, err)
+
+ // This patch is legit and should return a commit
+ oldCommit, err := repo.ReadPatchCommit(2)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ assert.Empty(t, noFile)
+ assert.Empty(t, noCommit)
+ assert.Len(t, oldCommit, 40)
+ assert.Equal(t, "6e8e2a6f9efd71dbe6917816343ed8415ad696c3", oldCommit)
+}
+
+func TestReadWritePullHead(t *testing.T) {
+ // Ensure we can write SHA1 head corresponding to PR and open them
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ // As we are writing we should clone the repository first
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ repo, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+
+ // Try to open non-existing Pull
+ _, err = repo.GetRefCommitID(PullPrefix + "0/head")
+ require.Error(t, err)
+
+ // Write a fake sha1 with only 40 zeros
+ newCommit := "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"
+ err = repo.SetReference(PullPrefix+"1/head", newCommit)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ // Read the file created
+ headContents, err := repo.GetRefCommitID(PullPrefix + "1/head")
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ assert.Len(t, headContents, 40)
+ assert.Equal(t, newCommit, headContents)
+
+ // Remove file after the test
+ err = repo.RemoveReference(PullPrefix + "1/head")
+ require.NoError(t, err)
+}
+
+func TestGetCommitFilesChanged(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+
+ testCases := []struct {
+ base, head string
+ files []string
+ }{
+ {
+ objectFormat.EmptyObjectID().String(),
+ "95bb4d39648ee7e325106df01a621c530863a653",
+ []string{"file1.txt"},
+ },
+ {
+ objectFormat.EmptyObjectID().String(),
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file2.txt"},
+ },
+ {
+ "95bb4d39648ee7e325106df01a621c530863a653",
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file2.txt"},
+ },
+ {
+ objectFormat.EmptyTree().String(),
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file1.txt", "file2.txt"},
+ },
+ }
+
+ for _, tc := range testCases {
+ changedFiles, err := repo.GetFilesChangedBetween(tc.base, tc.head)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, tc.files, changedFiles)
+ }
+}
diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go
new file mode 100644
index 0000000..e2b4506
--- /dev/null
+++ b/modules/git/repo_gpg.go
@@ -0,0 +1,58 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/modules/process"
+)
+
+// LoadPublicKeyContent will load the key from gpg
+func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
+ content, stderr, err := process.GetManager().Exec(
+ "gpg -a --export",
+ "gpg", "-a", "--export", gpgSettings.KeyID)
+ if err != nil {
+ return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err)
+ }
+ gpgSettings.PublicKeyContent = content
+ return nil
+}
+
+// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository
+func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
+ if repo.gpgSettings != nil && !forceUpdate {
+ return repo.gpgSettings, nil
+ }
+
+ gpgSettings := &GPGSettings{
+ Sign: true,
+ }
+
+ value, _, _ := NewCommand(repo.Ctx, "config", "--get", "commit.gpgsign").RunStdString(&RunOpts{Dir: repo.Path})
+ sign, valid := ParseBool(strings.TrimSpace(value))
+ if !sign || !valid {
+ gpgSettings.Sign = false
+ repo.gpgSettings = gpgSettings
+ return gpgSettings, nil
+ }
+
+ signingKey, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.signingkey").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.KeyID = strings.TrimSpace(signingKey)
+
+ defaultEmail, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.email").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.Email = strings.TrimSpace(defaultEmail)
+
+ defaultName, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.name").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.Name = strings.TrimSpace(defaultName)
+
+ if err := gpgSettings.LoadPublicKeyContent(); err != nil {
+ return nil, err
+ }
+ repo.gpgSettings = gpgSettings
+ return repo.gpgSettings, nil
+}
diff --git a/modules/git/repo_hook.go b/modules/git/repo_hook.go
new file mode 100644
index 0000000..cdf0765
--- /dev/null
+++ b/modules/git/repo_hook.go
@@ -0,0 +1,14 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+// GetHook get one hook according the name on a repository
+func (repo *Repository) GetHook(name string) (*Hook, error) {
+ return GetHook(repo.Path, name)
+}
+
+// Hooks get all the hooks on the repository
+func (repo *Repository) Hooks() ([]*Hook, error) {
+ return ListHooks(repo.Path)
+}
diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go
new file mode 100644
index 0000000..8390570
--- /dev/null
+++ b/modules/git/repo_index.go
@@ -0,0 +1,159 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ReadTreeToIndex reads a treeish to the index
+func (repo *Repository) ReadTreeToIndex(treeish string, indexFilename ...string) error {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return err
+ }
+
+ if len(treeish) != objectFormat.FullLength() {
+ res, _, err := NewCommand(repo.Ctx, "rev-parse", "--verify").AddDynamicArguments(treeish).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return err
+ }
+ if len(res) > 0 {
+ treeish = res[:len(res)-1]
+ }
+ }
+ id, err := NewIDFromString(treeish)
+ if err != nil {
+ return err
+ }
+ return repo.readTreeToIndex(id, indexFilename...)
+}
+
+func (repo *Repository) readTreeToIndex(id ObjectID, indexFilename ...string) error {
+ var env []string
+ if len(indexFilename) > 0 {
+ env = append(os.Environ(), "GIT_INDEX_FILE="+indexFilename[0])
+ }
+ _, _, err := NewCommand(repo.Ctx, "read-tree").AddDynamicArguments(id.String()).RunStdString(&RunOpts{Dir: repo.Path, Env: env})
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// ReadTreeToTemporaryIndex reads a treeish to a temporary index file
+func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (filename, tmpDir string, cancel context.CancelFunc, err error) {
+ tmpDir, err = os.MkdirTemp("", "index")
+ if err != nil {
+ return filename, tmpDir, cancel, err
+ }
+
+ filename = filepath.Join(tmpDir, ".tmp-index")
+ cancel = func() {
+ err := util.RemoveAll(tmpDir)
+ if err != nil {
+ log.Error("failed to remove tmp index file: %v", err)
+ }
+ }
+ err = repo.ReadTreeToIndex(treeish, filename)
+ if err != nil {
+ defer cancel()
+ return "", "", func() {}, err
+ }
+ return filename, tmpDir, cancel, err
+}
+
+// EmptyIndex empties the index
+func (repo *Repository) EmptyIndex() error {
+ _, _, err := NewCommand(repo.Ctx, "read-tree", "--empty").RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// LsFiles checks if the given filenames are in the index
+func (repo *Repository) LsFiles(filenames ...string) ([]string, error) {
+ cmd := NewCommand(repo.Ctx, "ls-files", "-z").AddDashesAndList(filenames...)
+ res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ filelist := make([]string, 0, len(filenames))
+ for _, line := range bytes.Split(res, []byte{'\000'}) {
+ filelist = append(filelist, string(line))
+ }
+
+ return filelist, err
+}
+
+// RemoveFilesFromIndex removes given filenames from the index - it does not check whether they are present.
+func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return err
+ }
+ cmd := NewCommand(repo.Ctx, "update-index", "--remove", "-z", "--index-info")
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ buffer := new(bytes.Buffer)
+ for _, file := range filenames {
+ if file != "" {
+ // using format: mode SP type SP sha1 TAB path
+ buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000")
+ }
+ }
+ return cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: bytes.NewReader(buffer.Bytes()),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+}
+
+type IndexObjectInfo struct {
+ Mode string
+ Object ObjectID
+ Filename string
+}
+
+// AddObjectsToIndex adds the provided object hashes to the index at the provided filenames
+func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error {
+ cmd := NewCommand(repo.Ctx, "update-index", "--add", "--replace", "-z", "--index-info")
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ buffer := new(bytes.Buffer)
+ for _, object := range objects {
+ // using format: mode SP type SP sha1 TAB path
+ buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000")
+ }
+ return cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: bytes.NewReader(buffer.Bytes()),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+}
+
+// AddObjectToIndex adds the provided object hash to the index at the provided filename
+func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename string) error {
+ return repo.AddObjectsToIndex(IndexObjectInfo{Mode: mode, Object: object, Filename: filename})
+}
+
+// WriteTree writes the current index as a tree to the object db and returns its hash
+func (repo *Repository) WriteTree() (*Tree, error) {
+ stdout, _, runErr := NewCommand(repo.Ctx, "write-tree").RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+ id, err := NewIDFromString(strings.TrimSpace(stdout))
+ if err != nil {
+ return nil, err
+ }
+ return NewTree(repo, id), nil
+}
diff --git a/modules/git/repo_language_stats.go b/modules/git/repo_language_stats.go
new file mode 100644
index 0000000..37c23fa
--- /dev/null
+++ b/modules/git/repo_language_stats.go
@@ -0,0 +1,251 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "cmp"
+ "io"
+ "strings"
+ "unicode"
+
+ "code.gitea.io/gitea/modules/analyze"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+
+ "github.com/go-enry/go-enry/v2"
+)
+
+const (
+ fileSizeLimit int64 = 16 * 1024 // 16 KiB
+ bigFileSize int64 = 1024 * 1024 // 1 MiB
+)
+
+// mergeLanguageStats mergers language names with different cases. The name with most upper case letters is used.
+func mergeLanguageStats(stats map[string]int64) map[string]int64 {
+ names := map[string]struct {
+ uniqueName string
+ upperCount int
+ }{}
+
+ countUpper := func(s string) (count int) {
+ for _, r := range s {
+ if unicode.IsUpper(r) {
+ count++
+ }
+ }
+ return count
+ }
+
+ for name := range stats {
+ cnt := countUpper(name)
+ lower := strings.ToLower(name)
+ if cnt >= names[lower].upperCount {
+ names[lower] = struct {
+ uniqueName string
+ upperCount int
+ }{uniqueName: name, upperCount: cnt}
+ }
+ }
+
+ res := make(map[string]int64, len(names))
+ for name, num := range stats {
+ res[names[strings.ToLower(name)].uniqueName] += num
+ }
+ return res
+}
+
+// GetLanguageStats calculates language stats for git repository at specified commit
+func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, error) {
+ // We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ writeID := func(id string) error {
+ _, err := batchStdinWriter.Write([]byte(id + "\n"))
+ return err
+ }
+
+ if err := writeID(commitID); err != nil {
+ return nil, err
+ }
+ shaBytes, typ, size, err := ReadBatchLine(batchReader)
+ if typ != "commit" {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, ErrNotExist{commitID, ""}
+ }
+
+ sha, err := NewIDFromString(string(shaBytes))
+ if err != nil {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, ErrNotExist{commitID, ""}
+ }
+
+ commit, err := CommitFromReader(repo, sha, io.LimitReader(batchReader, size))
+ if err != nil {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, err
+ }
+ if _, err = batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+
+ tree := commit.Tree
+
+ entries, err := tree.ListEntriesRecursiveWithSize()
+ if err != nil {
+ return nil, err
+ }
+
+ checker, err := repo.GitAttributeChecker(commitID, LinguistAttributes...)
+ if err != nil {
+ return nil, err
+ }
+ defer checker.Close()
+
+ contentBuf := bytes.Buffer{}
+ var content []byte
+
+ // sizes contains the current calculated size of all files by language
+ sizes := make(map[string]int64)
+ // by default we will only count the sizes of programming languages or markup languages
+ // unless they are explicitly set using linguist-language
+ includedLanguage := map[string]bool{}
+ // or if there's only one language in the repository
+ firstExcludedLanguage := ""
+ firstExcludedLanguageSize := int64(0)
+
+ isTrue := func(v optional.Option[bool]) bool {
+ return v.ValueOrDefault(false)
+ }
+ isFalse := func(v optional.Option[bool]) bool {
+ return !v.ValueOrDefault(true)
+ }
+
+ for _, f := range entries {
+ select {
+ case <-repo.Ctx.Done():
+ return sizes, repo.Ctx.Err()
+ default:
+ }
+
+ contentBuf.Reset()
+ content = contentBuf.Bytes()
+
+ if f.Size() == 0 {
+ continue
+ }
+
+ isVendored := optional.None[bool]()
+ isGenerated := optional.None[bool]()
+ isDocumentation := optional.None[bool]()
+ isDetectable := optional.None[bool]()
+
+ attrs, err := checker.CheckPath(f.Name())
+ if err == nil {
+ isVendored = attrs["linguist-vendored"].Bool()
+ isGenerated = attrs["linguist-generated"].Bool()
+ isDocumentation = attrs["linguist-documentation"].Bool()
+ isDetectable = attrs["linguist-detectable"].Bool()
+ if language := cmp.Or(
+ attrs["linguist-language"].String(),
+ attrs["gitlab-language"].Prefix(),
+ ); language != "" {
+ // group languages, such as Pug -> HTML; SCSS -> CSS
+ group := enry.GetLanguageGroup(language)
+ if len(group) != 0 {
+ language = group
+ }
+
+ // this language will always be added to the size
+ sizes[language] += f.Size()
+ continue
+ }
+ }
+
+ if isFalse(isDetectable) || isTrue(isVendored) || isTrue(isDocumentation) ||
+ (!isFalse(isVendored) && analyze.IsVendor(f.Name())) ||
+ enry.IsDotFile(f.Name()) ||
+ enry.IsConfiguration(f.Name()) ||
+ (!isFalse(isDocumentation) && enry.IsDocumentation(f.Name())) {
+ continue
+ }
+
+ // If content can not be read or file is too big just do detection by filename
+
+ if f.Size() <= bigFileSize {
+ if err := writeID(f.ID.String()); err != nil {
+ return nil, err
+ }
+ _, _, size, err := ReadBatchLine(batchReader)
+ if err != nil {
+ log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err)
+ return nil, err
+ }
+
+ sizeToRead := size
+ discard := int64(1)
+ if size > fileSizeLimit {
+ sizeToRead = fileSizeLimit
+ discard = size - fileSizeLimit + 1
+ }
+
+ _, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead))
+ if err != nil {
+ return nil, err
+ }
+ content = contentBuf.Bytes()
+ if err := DiscardFull(batchReader, discard); err != nil {
+ return nil, err
+ }
+ }
+ if !isTrue(isGenerated) && enry.IsGenerated(f.Name(), content) {
+ continue
+ }
+
+ // FIXME: Why can't we split this and the IsGenerated tests to avoid reading the blob unless absolutely necessary?
+ // - eg. do the all the detection tests using filename first before reading content.
+ language := analyze.GetCodeLanguage(f.Name(), content)
+ if language == "" {
+ continue
+ }
+
+ // group languages, such as Pug -> HTML; SCSS -> CSS
+ group := enry.GetLanguageGroup(language)
+ if group != "" {
+ language = group
+ }
+
+ included, checked := includedLanguage[language]
+ langType := enry.GetLanguageType(language)
+ if !checked {
+ included = langType == enry.Programming || langType == enry.Markup
+ if !included && (isTrue(isDetectable) || (langType == enry.Prose && isFalse(isDocumentation))) {
+ included = true
+ }
+ includedLanguage[language] = included
+ }
+ if included {
+ sizes[language] += f.Size()
+ } else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) {
+ // Only consider Programming or Markup languages as fallback
+ if !(langType == enry.Programming || langType == enry.Markup) {
+ continue
+ }
+ firstExcludedLanguage = language
+ firstExcludedLanguageSize += f.Size()
+ }
+ }
+
+ // If there are no included languages add the first excluded language
+ if len(sizes) == 0 && firstExcludedLanguage != "" {
+ sizes[firstExcludedLanguage] = firstExcludedLanguageSize
+ }
+
+ return mergeLanguageStats(sizes), nil
+}
diff --git a/modules/git/repo_language_stats_test.go b/modules/git/repo_language_stats_test.go
new file mode 100644
index 0000000..fd80e44
--- /dev/null
+++ b/modules/git/repo_language_stats_test.go
@@ -0,0 +1,42 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetLanguageStats(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+
+ defer gitRepo.Close()
+
+ stats, err := gitRepo.GetLanguageStats("8fee858da5796dfb37704761701bb8e800ad9ef3")
+ require.NoError(t, err)
+
+ assert.EqualValues(t, map[string]int64{
+ "Python": 134,
+ "Java": 112,
+ }, stats)
+}
+
+func TestMergeLanguageStats(t *testing.T) {
+ assert.EqualValues(t, map[string]int64{
+ "PHP": 1,
+ "python": 10,
+ "JAVA": 700,
+ }, mergeLanguageStats(map[string]int64{
+ "PHP": 1,
+ "python": 10,
+ "Java": 100,
+ "java": 200,
+ "JAVA": 400,
+ }))
+}
diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go
new file mode 100644
index 0000000..3d48b91
--- /dev/null
+++ b/modules/git/repo_object.go
@@ -0,0 +1,101 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "strings"
+)
+
+// ObjectType git object type
+type ObjectType string
+
+const (
+ // ObjectCommit commit object type
+ ObjectCommit ObjectType = "commit"
+ // ObjectTree tree object type
+ ObjectTree ObjectType = "tree"
+ // ObjectBlob blob object type
+ ObjectBlob ObjectType = "blob"
+ // ObjectTag tag object type
+ ObjectTag ObjectType = "tag"
+ // ObjectBranch branch object type
+ ObjectBranch ObjectType = "branch"
+)
+
+// Bytes returns the byte array for the Object Type
+func (o ObjectType) Bytes() []byte {
+ return []byte(o)
+}
+
+type EmptyReader struct{}
+
+func (EmptyReader) Read(p []byte) (int, error) {
+ return 0, io.EOF
+}
+
+func (repo *Repository) GetObjectFormat() (ObjectFormat, error) {
+ if repo != nil && repo.objectFormat != nil {
+ return repo.objectFormat, nil
+ }
+
+ str, err := repo.hashObject(EmptyReader{}, false)
+ if err != nil {
+ return nil, err
+ }
+ hash, err := NewIDFromString(str)
+ if err != nil {
+ return nil, err
+ }
+
+ repo.objectFormat = hash.Type()
+
+ return repo.objectFormat, nil
+}
+
+// HashObject takes a reader and returns hash for that reader
+func (repo *Repository) HashObject(reader io.Reader) (ObjectID, error) {
+ idStr, err := repo.hashObject(reader, true)
+ if err != nil {
+ return nil, err
+ }
+ return NewIDFromString(idStr)
+}
+
+func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) {
+ var cmd *Command
+ if save {
+ cmd = NewCommand(repo.Ctx, "hash-object", "-w", "--stdin")
+ } else {
+ cmd = NewCommand(repo.Ctx, "hash-object", "--stdin")
+ }
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ err := cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: reader,
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+// GetRefType gets the type of the ref based on the string
+func (repo *Repository) GetRefType(ref string) ObjectType {
+ if repo.IsTagExist(ref) {
+ return ObjectTag
+ } else if repo.IsBranchExist(ref) {
+ return ObjectBranch
+ } else if repo.IsCommitExist(ref) {
+ return ObjectCommit
+ } else if _, err := repo.GetBlob(ref); err == nil {
+ return ObjectBlob
+ }
+ return ObjectType("invalid")
+}
diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go
new file mode 100644
index 0000000..550c653
--- /dev/null
+++ b/modules/git/repo_ref.go
@@ -0,0 +1,157 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// GetRefs returns all references of the repository.
+func (repo *Repository) GetRefs() ([]*Reference, error) {
+ return repo.GetRefsFiltered("")
+}
+
+// ListOccurrences lists all refs of the given refType the given commit appears in sorted by creation date DESC
+// refType should only be a literal "branch" or "tag" and nothing else
+func (repo *Repository) ListOccurrences(ctx context.Context, refType, commitSHA string) ([]string, error) {
+ cmd := NewCommand(ctx)
+ if refType == "branch" {
+ cmd.AddArguments("branch")
+ } else if refType == "tag" {
+ cmd.AddArguments("tag")
+ } else {
+ return nil, util.NewInvalidArgumentErrorf(`can only use "branch" or "tag" for refType, but got %q`, refType)
+ }
+ stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains").AddDynamicArguments(commitSHA).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ refs := strings.Split(strings.TrimSpace(stdout), "\n")
+ if refType == "branch" {
+ return parseBranches(refs), nil
+ }
+ return parseTags(refs), nil
+}
+
+func parseBranches(refs []string) []string {
+ results := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ if strings.HasPrefix(ref, "* ") { // current branch (main branch)
+ results = append(results, ref[len("* "):])
+ } else if strings.HasPrefix(ref, " ") { // all other branches
+ results = append(results, ref[len(" "):])
+ } else if ref != "" {
+ results = append(results, ref)
+ }
+ }
+ return results
+}
+
+func parseTags(refs []string) []string {
+ results := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ if ref != "" {
+ results = append(results, ref)
+ }
+ }
+ return results
+}
+
+// ExpandRef expands any partial reference to its full form
+func (repo *Repository) ExpandRef(ref string) (string, error) {
+ if strings.HasPrefix(ref, "refs/") {
+ return ref, nil
+ } else if strings.HasPrefix(ref, "tags/") || strings.HasPrefix(ref, "heads/") {
+ return "refs/" + ref, nil
+ } else if repo.IsTagExist(ref) {
+ return TagPrefix + ref, nil
+ } else if repo.IsBranchExist(ref) {
+ return BranchPrefix + ref, nil
+ } else if repo.IsCommitExist(ref) {
+ return ref, nil
+ }
+ return "", fmt.Errorf("could not expand reference '%s'", ref)
+}
+
+// GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with.
+func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) {
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ go func() {
+ stderrBuilder := &strings.Builder{}
+ err := NewCommand(repo.Ctx, "for-each-ref").Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderrBuilder,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderrBuilder.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ refs := make([]*Reference, 0)
+ bufReader := bufio.NewReader(stdoutReader)
+ for {
+ // The output of for-each-ref is simply a list:
+ // <sha> SP <type> TAB <ref> LF
+ sha, err := bufReader.ReadString(' ')
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ sha = sha[:len(sha)-1]
+
+ typ, err := bufReader.ReadString('\t')
+ if err == io.EOF {
+ // This should not happen, but we'll tolerate it
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ typ = typ[:len(typ)-1]
+
+ refName, err := bufReader.ReadString('\n')
+ if err == io.EOF {
+ // This should not happen, but we'll tolerate it
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ refName = refName[:len(refName)-1]
+
+ // refName cannot be HEAD but can be remotes or stash
+ if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" {
+ continue
+ }
+
+ if pattern == "" || strings.HasPrefix(refName, pattern) {
+ r := &Reference{
+ Name: refName,
+ Object: MustIDFromString(sha),
+ Type: typ,
+ repo: repo,
+ }
+ refs = append(refs, r)
+ }
+ }
+
+ return refs, nil
+}
diff --git a/modules/git/repo_ref_test.go b/modules/git/repo_ref_test.go
new file mode 100644
index 0000000..609bef5
--- /dev/null
+++ b/modules/git/repo_ref_test.go
@@ -0,0 +1,56 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetRefs(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ refs, err := bareRepo1.GetRefs()
+
+ require.NoError(t, err)
+ assert.Len(t, refs, 6)
+
+ expectedRefs := []string{
+ BranchPrefix + "branch1",
+ BranchPrefix + "branch2",
+ BranchPrefix + "master",
+ TagPrefix + "test",
+ TagPrefix + "signed-tag",
+ NotesRef,
+ }
+
+ for _, ref := range refs {
+ assert.Contains(t, expectedRefs, ref.Name)
+ }
+}
+
+func TestRepository_GetRefsFiltered(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ refs, err := bareRepo1.GetRefsFiltered(TagPrefix)
+
+ require.NoError(t, err)
+ if assert.Len(t, refs, 2) {
+ assert.Equal(t, TagPrefix+"signed-tag", refs[0].Name)
+ assert.Equal(t, "tag", refs[0].Type)
+ assert.Equal(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", refs[0].Object.String())
+ assert.Equal(t, TagPrefix+"test", refs[1].Name)
+ assert.Equal(t, "tag", refs[1].Type)
+ assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[1].Object.String())
+ }
+}
diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go
new file mode 100644
index 0000000..8322010
--- /dev/null
+++ b/modules/git/repo_stats.go
@@ -0,0 +1,151 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/container"
+)
+
+// CodeActivityStats represents git statistics data
+type CodeActivityStats struct {
+ AuthorCount int64
+ CommitCount int64
+ ChangedFiles int64
+ Additions int64
+ Deletions int64
+ CommitCountInAllBranches int64
+ Authors []*CodeActivityAuthor
+}
+
+// CodeActivityAuthor represents git statistics data for commit authors
+type CodeActivityAuthor struct {
+ Name string
+ Email string
+ Commits int64
+}
+
+// GetCodeActivityStats returns code statistics for activity page
+func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) {
+ stats := &CodeActivityStats{}
+
+ since := fromTime.Format(time.RFC3339)
+
+ stdout, _, runErr := NewCommand(repo.Ctx, "rev-list", "--count", "--no-merges", "--branches=*", "--date=iso").AddOptionFormat("--since='%s'", since).RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ c, err := strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ stats.CommitCountInAllBranches = c
+
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ gitCmd := NewCommand(repo.Ctx, "log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso").AddOptionFormat("--since='%s'", since)
+ if len(branch) == 0 {
+ gitCmd.AddArguments("--branches=*")
+ } else {
+ gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch)
+ }
+
+ stderr := new(strings.Builder)
+ err = gitCmd.Run(&RunOpts{
+ Env: []string{},
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ scanner := bufio.NewScanner(stdoutReader)
+ scanner.Split(bufio.ScanLines)
+ stats.CommitCount = 0
+ stats.Additions = 0
+ stats.Deletions = 0
+ authors := make(map[string]*CodeActivityAuthor)
+ files := make(container.Set[string])
+ var author string
+ p := 0
+ for scanner.Scan() {
+ l := strings.TrimSpace(scanner.Text())
+ if l == "---" {
+ p = 1
+ } else if p == 0 {
+ continue
+ } else {
+ p++
+ }
+ if p > 4 && len(l) == 0 {
+ continue
+ }
+ switch p {
+ case 1: // Separator
+ case 2: // Commit sha-1
+ stats.CommitCount++
+ case 3: // Author
+ author = l
+ case 4: // E-mail
+ email := strings.ToLower(l)
+ if _, ok := authors[email]; !ok {
+ authors[email] = &CodeActivityAuthor{Name: author, Email: email, Commits: 0}
+ }
+ authors[email].Commits++
+ default: // Changed file
+ if parts := strings.Fields(l); len(parts) >= 3 {
+ if parts[0] != "-" {
+ if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil {
+ stats.Additions += c
+ }
+ }
+ if parts[1] != "-" {
+ if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil {
+ stats.Deletions += c
+ }
+ }
+ files.Add(parts[2])
+ }
+ }
+ }
+ if err = scanner.Err(); err != nil {
+ _ = stdoutReader.Close()
+ return fmt.Errorf("GetCodeActivityStats scan: %w", err)
+ }
+ a := make([]*CodeActivityAuthor, 0, len(authors))
+ for _, v := range authors {
+ a = append(a, v)
+ }
+ // Sort authors descending depending on commit count
+ sort.Slice(a, func(i, j int) bool {
+ return a[i].Commits > a[j].Commits
+ })
+ stats.AuthorCount = int64(len(authors))
+ stats.ChangedFiles = int64(len(files))
+ stats.Authors = a
+ _ = stdoutReader.Close()
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr)
+ }
+
+ return stats, nil
+}
diff --git a/modules/git/repo_stats_test.go b/modules/git/repo_stats_test.go
new file mode 100644
index 0000000..2a15b6f
--- /dev/null
+++ b/modules/git/repo_stats_test.go
@@ -0,0 +1,37 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetCodeActivityStats(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ timeFrom, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00")
+ require.NoError(t, err)
+
+ code, err := bareRepo1.GetCodeActivityStats(timeFrom, "")
+ require.NoError(t, err)
+ assert.NotNil(t, code)
+
+ assert.EqualValues(t, 10, code.CommitCount)
+ assert.EqualValues(t, 3, code.AuthorCount)
+ assert.EqualValues(t, 10, code.CommitCountInAllBranches)
+ assert.EqualValues(t, 10, code.Additions)
+ assert.EqualValues(t, 1, code.Deletions)
+ assert.Len(t, code.Authors, 3)
+ assert.EqualValues(t, "tris.git@shoddynet.org", code.Authors[1].Email)
+ assert.EqualValues(t, 3, code.Authors[1].Commits)
+ assert.EqualValues(t, 5, code.Authors[0].Commits)
+}
diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go
new file mode 100644
index 0000000..12b0c02
--- /dev/null
+++ b/modules/git/repo_tag.go
@@ -0,0 +1,366 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// TagPrefix tags prefix path on the repository
+const TagPrefix = "refs/tags/"
+
+// IsTagExist returns true if given tag exists in the repository.
+func IsTagExist(ctx context.Context, repoPath, name string) bool {
+ return IsReferenceExist(ctx, repoPath, TagPrefix+name)
+}
+
+// CreateTag create one tag in the repository
+func (repo *Repository) CreateTag(name, revision string) error {
+ _, _, err := NewCommand(repo.Ctx, "tag").AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// CreateAnnotatedTag create one annotated tag in the repository
+func (repo *Repository) CreateAnnotatedTag(name, message, revision string) error {
+ _, _, err := NewCommand(repo.Ctx, "tag", "-a", "-m").AddDynamicArguments(message).AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// GetTagNameBySHA returns the name of a tag from its tag object SHA or commit SHA
+func (repo *Repository) GetTagNameBySHA(sha string) (string, error) {
+ if len(sha) < 5 {
+ return "", fmt.Errorf("SHA is too short: %s", sha)
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--tags", "-d").RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return "", err
+ }
+
+ tagRefs := strings.Split(stdout, "\n")
+ for _, tagRef := range tagRefs {
+ if len(strings.TrimSpace(tagRef)) > 0 {
+ fields := strings.Fields(tagRef)
+ if strings.HasPrefix(fields[0], sha) && strings.HasPrefix(fields[1], TagPrefix) {
+ name := fields[1][len(TagPrefix):]
+ // annotated tags show up twice, we should only return if is not the ^{} ref
+ if !strings.HasSuffix(name, "^{}") {
+ return name, nil
+ }
+ }
+ }
+ }
+ return "", ErrNotExist{ID: sha}
+}
+
+// GetTagID returns the object ID for a tag (annotated tags have both an object SHA AND a commit SHA)
+func (repo *Repository) GetTagID(name string) (string, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--tags").AddDashesAndList(name).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return "", err
+ }
+ // Make sure exact match is used: "v1" != "release/v1"
+ for _, line := range strings.Split(stdout, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == "refs/tags/"+name {
+ return fields[0], nil
+ }
+ }
+ return "", ErrNotExist{ID: name}
+}
+
+// GetTag returns a Git tag by given name.
+func (repo *Repository) GetTag(name string) (*Tag, error) {
+ idStr, err := repo.GetTagID(name)
+ if err != nil {
+ return nil, err
+ }
+
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// GetTagWithID returns a Git tag by given name and ID
+func (repo *Repository) GetTagWithID(idStr, name string) (*Tag, error) {
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// GetTagInfos returns all tag infos of the repository.
+func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) {
+ // Generally, refname:short should be equal to refname:lstrip=2 except core.warnAmbiguousRefs is used to select the strict abbreviation mode.
+ // https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname
+ forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature")
+
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer stdoutReader.Close()
+ defer stdoutWriter.Close()
+ stderr := strings.Builder{}
+ rc := &RunOpts{Dir: repo.Path, Stdout: stdoutWriter, Stderr: &stderr}
+
+ go func() {
+ err := NewCommand(repo.Ctx, "for-each-ref").
+ AddOptionFormat("--format=%s", forEachRefFmt.Flag()).
+ AddArguments("--sort", "-*creatordate", "refs/tags").Run(rc)
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderr.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ var tags []*Tag
+ parser := forEachRefFmt.Parser(stdoutReader)
+ for {
+ ref := parser.Next()
+ if ref == nil {
+ break
+ }
+
+ tag, err := parseTagRef(ref)
+ if err != nil {
+ return nil, 0, fmt.Errorf("GetTagInfos: parse tag: %w", err)
+ }
+ tags = append(tags, tag)
+ }
+ if err := parser.Err(); err != nil {
+ return nil, 0, fmt.Errorf("GetTagInfos: parse output: %w", err)
+ }
+
+ sortTagsByTime(tags)
+ tagsTotal := len(tags)
+ if page != 0 {
+ tags = util.PaginateSlice(tags, page, pageSize).([]*Tag)
+ }
+
+ return tags, tagsTotal, nil
+}
+
+// parseTagRef parses a tag from a 'git for-each-ref'-produced reference.
+func parseTagRef(ref map[string]string) (tag *Tag, err error) {
+ tag = &Tag{
+ Type: ref["objecttype"],
+ Name: ref["refname:lstrip=2"],
+ }
+
+ tag.ID, err = NewIDFromString(ref["objectname"])
+ if err != nil {
+ return nil, fmt.Errorf("parse objectname '%s': %w", ref["objectname"], err)
+ }
+
+ if tag.Type == "commit" {
+ // lightweight tag
+ tag.Object = tag.ID
+ } else {
+ // annotated tag
+ tag.Object, err = NewIDFromString(ref["object"])
+ if err != nil {
+ return nil, fmt.Errorf("parse object '%s': %w", ref["object"], err)
+ }
+ }
+
+ tag.Tagger = parseSignatureFromCommitLine(ref["creator"])
+ tag.Message = ref["contents"]
+ // strip the signature if present in contents field
+ pgpStart := strings.Index(tag.Message, beginpgp)
+ if pgpStart >= 0 {
+ tag.Message = tag.Message[0:pgpStart]
+ } else {
+ sshStart := strings.Index(tag.Message, beginssh)
+ if sshStart >= 0 {
+ tag.Message = tag.Message[0:sshStart]
+ }
+ }
+
+ // annotated tag with signature
+ if tag.Type == "tag" && ref["contents:signature"] != "" {
+ payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s\n",
+ tag.Object, tag.Name, ref["creator"], strings.TrimSpace(tag.Message))
+ tag.Signature = &ObjectSignature{
+ Signature: ref["contents:signature"],
+ Payload: payload,
+ }
+ }
+
+ return tag, nil
+}
+
+// GetAnnotatedTag returns a Git tag by its SHA, must be an annotated tag
+func (repo *Repository) GetAnnotatedTag(sha string) (*Tag, error) {
+ id, err := NewIDFromString(sha)
+ if err != nil {
+ return nil, err
+ }
+
+ // Tag type must be "tag" (annotated) and not a "commit" (lightweight) tag
+ if tagType, err := repo.GetTagType(id); err != nil {
+ return nil, err
+ } else if ObjectType(tagType) != ObjectTag {
+ // not an annotated tag
+ return nil, ErrNotExist{ID: id.String()}
+ }
+
+ // Get tag name
+ name, err := repo.GetTagNameBySHA(id.String())
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// IsTagExist returns true if given tag exists in the repository.
+func (repo *Repository) IsTagExist(name string) bool {
+ if repo == nil || name == "" {
+ return false
+ }
+
+ return repo.IsReferenceExist(TagPrefix + name)
+}
+
+// GetTags returns all tags of the repository.
+// returning at most limit tags, or all if limit is 0.
+func (repo *Repository) GetTags(skip, limit int) (tags []string, err error) {
+ tags, _, err = callShowRef(repo.Ctx, repo.Path, TagPrefix, TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}, skip, limit)
+ return tags, err
+}
+
+// GetTagType gets the type of the tag, either commit (simple) or tag (annotated)
+func (repo *Repository) GetTagType(id ObjectID) (string, error) {
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return "", err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(id.String() + "\n"))
+ if err != nil {
+ return "", err
+ }
+ _, typ, _, err := ReadBatchLine(rd)
+ if IsErrNotExist(err) {
+ return "", ErrNotExist{ID: id.String()}
+ }
+ return typ, nil
+}
+
+func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
+ t, ok := repo.tagCache.Get(tagID.String())
+ if ok {
+ log.Debug("Hit cache: %s", tagID)
+ tagClone := *t.(*Tag)
+ tagClone.Name = name // This is necessary because lightweight tags may have same id
+ return &tagClone, nil
+ }
+
+ tp, err := repo.GetTagType(tagID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the commit ID and tag ID (may be different for annotated tag) for the returned tag object
+ commitIDStr, err := repo.GetTagCommitID(name)
+ if err != nil {
+ // every tag should have a commit ID so return all errors
+ return nil, err
+ }
+ commitID, err := NewIDFromString(commitIDStr)
+ if err != nil {
+ return nil, err
+ }
+
+ // If type is "commit, the tag is a lightweight tag
+ if ObjectType(tp) == ObjectCommit {
+ commit, err := repo.GetCommit(commitIDStr)
+ if err != nil {
+ return nil, err
+ }
+ tag := &Tag{
+ Name: name,
+ ID: tagID,
+ Object: commitID,
+ Type: tp,
+ Tagger: commit.Committer,
+ Message: commit.Message(),
+ }
+
+ repo.tagCache.Set(tagID.String(), tag)
+ return tag, nil
+ }
+
+ // The tag is an annotated tag with a message.
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ if _, err := wr.Write([]byte(tagID.String() + "\n")); err != nil {
+ return nil, err
+ }
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ if errors.Is(err, io.EOF) || IsErrNotExist(err) {
+ return nil, ErrNotExist{ID: tagID.String()}
+ }
+ return nil, err
+ }
+ if typ != "tag" {
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{ID: tagID.String()}
+ }
+
+ // then we need to parse the tag
+ // and load the commit
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := parseTagData(tagID.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+
+ tag.Name = name
+ tag.ID = tagID
+ tag.Type = tp
+
+ repo.tagCache.Set(tagID.String(), tag)
+ return tag, nil
+}
diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go
new file mode 100644
index 0000000..1cf420a
--- /dev/null
+++ b/modules/git/repo_tag_test.go
@@ -0,0 +1,364 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetTags(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ tags, total, err := bareRepo1.GetTagInfos(0, 0)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.Len(t, tags, 2)
+ assert.Len(t, tags, total)
+ assert.EqualValues(t, "signed-tag", tags[0].Name)
+ assert.EqualValues(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", tags[0].ID.String())
+ assert.EqualValues(t, "tag", tags[0].Type)
+ assert.EqualValues(t, "test", tags[1].Name)
+ assert.EqualValues(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", tags[1].ID.String())
+ assert.EqualValues(t, "tag", tags[1].Type)
+}
+
+func TestRepository_GetTag(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ bareRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ // LIGHTWEIGHT TAGS
+ lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1"
+ lTagName := "lightweightTag"
+
+ // Create the lightweight tag
+ err = bareRepo1.CreateTag(lTagName, lTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the lightweight tag: %s for ID: %s. Error: %v", lTagName, lTagCommitID, err)
+ return
+ }
+
+ // and try to get the Tag for lightweight tag
+ lTag, err := bareRepo1.GetTag(lTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ if lTag == nil {
+ assert.NotNil(t, lTag)
+ assert.FailNow(t, "nil lTag: %s", lTagName)
+ }
+ assert.EqualValues(t, lTagName, lTag.Name)
+ assert.EqualValues(t, lTagCommitID, lTag.ID.String())
+ assert.EqualValues(t, lTagCommitID, lTag.Object.String())
+ assert.EqualValues(t, "commit", lTag.Type)
+
+ // ANNOTATED TAGS
+ aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ aTagName := "annotatedTag"
+ aTagMessage := "my annotated message \n - test two line"
+
+ // Create the annotated tag
+ err = bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the annotated tag: %s for ID: %s. Error: %v", aTagName, aTagCommitID, err)
+ return
+ }
+
+ // Now try to get the tag for the annotated Tag
+ aTagID, err := bareRepo1.GetTagID(aTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ aTag, err := bareRepo1.GetTag(aTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ if aTag == nil {
+ assert.NotNil(t, aTag)
+ assert.FailNow(t, "nil aTag: %s", aTagName)
+ }
+ assert.EqualValues(t, aTagName, aTag.Name)
+ assert.EqualValues(t, aTagID, aTag.ID.String())
+ assert.NotEqual(t, aTagID, aTag.Object.String())
+ assert.EqualValues(t, aTagCommitID, aTag.Object.String())
+ assert.EqualValues(t, "tag", aTag.Type)
+
+ // RELEASE TAGS
+
+ rTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ rTagName := "release/" + lTagName
+
+ err = bareRepo1.CreateTag(rTagName, rTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the tag: %s for ID: %s. Error: %v", rTagName, rTagCommitID, err)
+ return
+ }
+
+ rTagID, err := bareRepo1.GetTagID(rTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.EqualValues(t, rTagCommitID, rTagID)
+
+ oTagID, err := bareRepo1.GetTagID(lTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.EqualValues(t, lTagCommitID, oTagID)
+}
+
+func TestRepository_GetAnnotatedTag(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ bareRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1"
+ lTagName := "lightweightTag"
+ bareRepo1.CreateTag(lTagName, lTagCommitID)
+
+ aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ aTagName := "annotatedTag"
+ aTagMessage := "my annotated message"
+ bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID)
+ aTagID, _ := bareRepo1.GetTagID(aTagName)
+
+ // Try an annotated tag
+ tag, err := bareRepo1.GetAnnotatedTag(aTagID)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.NotNil(t, tag)
+ assert.EqualValues(t, aTagName, tag.Name)
+ assert.EqualValues(t, aTagID, tag.ID.String())
+ assert.EqualValues(t, "tag", tag.Type)
+
+ // Annotated tag's Commit ID should fail
+ tag2, err := bareRepo1.GetAnnotatedTag(aTagCommitID)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ assert.Nil(t, tag2)
+
+ // Annotated tag's name should fail
+ tag3, err := bareRepo1.GetAnnotatedTag(aTagName)
+ require.Error(t, err)
+ require.Errorf(t, err, "Length must be 40: %d", len(aTagName))
+ assert.Nil(t, tag3)
+
+ // Lightweight Tag should fail
+ tag4, err := bareRepo1.GetAnnotatedTag(lTagCommitID)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ assert.Nil(t, tag4)
+}
+
+func TestRepository_parseTagRef(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenRef map[string]string
+
+ want *Tag
+ wantErr bool
+ expectedErr error
+ }{
+ {
+ name: "lightweight tag",
+
+ givenRef: map[string]string{
+ "objecttype": "commit",
+ "refname:lstrip=2": "v1.9.1",
+ // object will be empty for lightweight tags
+ "object": "",
+ "objectname": "ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ "contents:signature": "",
+ },
+
+ want: &Tag{
+ Name: "v1.9.1",
+ ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
+ Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
+ Type: "commit",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
+ Signature: nil,
+ },
+ },
+
+ {
+ name: "annotated tag",
+
+ givenRef: map[string]string{
+ "objecttype": "tag",
+ "refname:lstrip=2": "v0.0.1",
+ // object will refer to commit hash for annotated tag
+ "object": "3325fd8a973321fd59455492976c042dde3fd1ca",
+ "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ "contents:signature": "",
+ },
+
+ want: &Tag{
+ Name: "v0.0.1",
+ ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
+ Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
+ Type: "tag",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
+ Signature: nil,
+ },
+ },
+
+ {
+ name: "annotated tag with signature",
+
+ givenRef: map[string]string{
+ "objecttype": "tag",
+ "refname:lstrip=2": "v0.0.1",
+ "object": "3325fd8a973321fd59455492976c042dde3fd1ca",
+ "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ "contents:signature": `-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ },
+
+ want: &Tag{
+ Name: "v0.0.1",
+ ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
+ Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
+ Type: "tag",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md",
+ Signature: &ObjectSignature{
+ Signature: `-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ Payload: `object 3325fd8a973321fd59455492976c042dde3fd1ca
+type commit
+tag v0.0.1
+tagger Foo Bar <foo@bar.com> 1565789218 +0300
+
+Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseTagRef(tc.givenRef)
+
+ if tc.wantErr {
+ require.Error(t, err)
+ require.ErrorIs(t, err, tc.expectedErr)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tc.want, got)
+ }
+ })
+ }
+}
diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go
new file mode 100644
index 0000000..8fb19a5
--- /dev/null
+++ b/modules/git/repo_test.go
@@ -0,0 +1,57 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetLatestCommitTime(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ lct, err := GetLatestCommitTime(DefaultContext, bareRepo1Path)
+ require.NoError(t, err)
+ // Time is Sun Nov 13 16:40:14 2022 +0100
+ // which is the time of commit
+ // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
+ assert.EqualValues(t, 1668354014, lct.Unix())
+}
+
+func TestRepoIsEmpty(t *testing.T) {
+ emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty")
+ repo, err := openRepositoryWithDefaultContext(emptyRepo2Path)
+ require.NoError(t, err)
+ defer repo.Close()
+ isEmpty, err := repo.IsEmpty()
+ require.NoError(t, err)
+ assert.True(t, isEmpty)
+}
+
+func TestRepoGetDivergingCommits(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ do, err := GetDivergingCommits(context.Background(), bareRepo1Path, "master", "branch2")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 1,
+ Behind: 5,
+ }, do)
+
+ do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "master")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 0,
+ Behind: 0,
+ }, do)
+
+ do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "test")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 0,
+ Behind: 2,
+ }, do)
+}
diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go
new file mode 100644
index 0000000..53d94d9
--- /dev/null
+++ b/modules/git/repo_tree.go
@@ -0,0 +1,156 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+// CommitTreeOpts represents the possible options to CommitTree
+type CommitTreeOpts struct {
+ Parents []string
+ Message string
+ KeyID string
+ NoGPGSign bool
+ AlwaysSign bool
+}
+
+// CommitTree creates a commit from a given tree id for the user with provided message
+func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opts CommitTreeOpts) (ObjectID, error) {
+ commitTimeStr := time.Now().Format(time.RFC3339)
+
+ // Because this may call hooks we should pass in the environment
+ env := append(os.Environ(),
+ "GIT_AUTHOR_NAME="+author.Name,
+ "GIT_AUTHOR_EMAIL="+author.Email,
+ "GIT_AUTHOR_DATE="+commitTimeStr,
+ "GIT_COMMITTER_NAME="+committer.Name,
+ "GIT_COMMITTER_EMAIL="+committer.Email,
+ "GIT_COMMITTER_DATE="+commitTimeStr,
+ )
+ cmd := NewCommand(repo.Ctx, "commit-tree").AddDynamicArguments(tree.ID.String())
+
+ for _, parent := range opts.Parents {
+ cmd.AddArguments("-p").AddDynamicArguments(parent)
+ }
+
+ messageBytes := new(bytes.Buffer)
+ _, _ = messageBytes.WriteString(opts.Message)
+ _, _ = messageBytes.WriteString("\n")
+
+ if opts.KeyID != "" || opts.AlwaysSign {
+ cmd.AddOptionFormat("-S%s", opts.KeyID)
+ }
+
+ if opts.NoGPGSign {
+ cmd.AddArguments("--no-gpg-sign")
+ }
+
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ err := cmd.Run(&RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdin: messageBytes,
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+ return NewIDFromString(strings.TrimSpace(stdout.String()))
+}
+
+func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(id.String() + "\n"))
+
+ // ignore the SHA
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+
+ switch typ {
+ case "tag":
+ resolvedID := id
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ tag, err := parseTagData(id.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+ commit, err := tag.Commit(repo)
+ if err != nil {
+ return nil, err
+ }
+ commit.Tree.ResolvedID = resolvedID
+ return &commit.Tree, nil
+ case "commit":
+ commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := rd.Discard(1); err != nil {
+ return nil, err
+ }
+ commit.Tree.ResolvedID = commit.ID
+ return &commit.Tree, nil
+ case "tree":
+ tree := NewTree(repo, id)
+ tree.ResolvedID = id
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size)
+ if err != nil {
+ return nil, err
+ }
+ tree.entriesParsed = true
+ return tree, nil
+ default:
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{
+ ID: id.String(),
+ }
+ }
+}
+
+// GetTree find the tree object in the repository.
+func (repo *Repository) GetTree(idStr string) (*Tree, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ if len(idStr) != objectFormat.FullLength() {
+ res, err := repo.GetRefCommitID(idStr)
+ if err != nil {
+ return nil, err
+ }
+ if len(res) > 0 {
+ idStr = res
+ }
+ }
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getTree(id)
+}
diff --git a/modules/git/signature.go b/modules/git/signature.go
new file mode 100644
index 0000000..c368ce3
--- /dev/null
+++ b/modules/git/signature.go
@@ -0,0 +1,67 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Signature represents the Author, Committer or Tagger information.
+type Signature struct {
+ Name string // the committer name, it can be anything
+ Email string // the committer email, it can be anything
+ When time.Time // the timestamp of the signature
+}
+
+func (s *Signature) String() string {
+ return fmt.Sprintf("%s <%s>", s.Name, s.Email)
+}
+
+// Decode decodes a byte array representing a signature to signature
+func (s *Signature) Decode(b []byte) {
+ *s = *parseSignatureFromCommitLine(util.UnsafeBytesToString(b))
+}
+
+// Helper to get a signature from the commit line, which looks like:
+//
+// full name <user@example.com> 1378823654 +0200
+//
+// Haven't found the official reference for the standard format yet.
+// This function never fails, if the "line" can't be parsed, it returns a default Signature with "zero" time.
+func parseSignatureFromCommitLine(line string) *Signature {
+ sig := &Signature{}
+ s1, sx, ok1 := strings.Cut(line, " <")
+ s2, s3, ok2 := strings.Cut(sx, "> ")
+ if !ok1 || !ok2 {
+ sig.Name = line
+ return sig
+ }
+ sig.Name, sig.Email = s1, s2
+
+ if strings.Count(s3, " ") == 1 {
+ ts, tz, _ := strings.Cut(s3, " ")
+ seconds, _ := strconv.ParseInt(ts, 10, 64)
+ if tzTime, err := time.Parse("-0700", tz); err == nil {
+ sig.When = time.Unix(seconds, 0).In(tzTime.Location())
+ }
+ } else {
+ // the old gitea code tried to parse the date in a few different formats, but it's not clear why.
+ // according to public document, only the standard format "timestamp timezone" could be found, so drop other formats.
+ log.Error("suspicious commit line format: %q", line)
+ for _, fmt := range []string{ /*"Mon Jan _2 15:04:05 2006 -0700"*/ } {
+ if t, err := time.Parse(fmt, s3); err == nil {
+ sig.When = t
+ break
+ }
+ }
+ }
+ return sig
+}
diff --git a/modules/git/signature_test.go b/modules/git/signature_test.go
new file mode 100644
index 0000000..92681fe
--- /dev/null
+++ b/modules/git/signature_test.go
@@ -0,0 +1,47 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseSignatureFromCommitLine(t *testing.T) {
+ tests := []struct {
+ line string
+ want *Signature
+ }{
+ {
+ line: "a b <c@d.com> 12345 +0100",
+ want: &Signature{
+ Name: "a b",
+ Email: "c@d.com",
+ When: time.Unix(12345, 0).In(time.FixedZone("", 3600)),
+ },
+ },
+ {
+ line: "bad line",
+ want: &Signature{Name: "bad line"},
+ },
+ {
+ line: "bad < line",
+ want: &Signature{Name: "bad < line"},
+ },
+ {
+ line: "bad > line",
+ want: &Signature{Name: "bad > line"},
+ },
+ {
+ line: "bad-line <name@example.com>",
+ want: &Signature{Name: "bad-line <name@example.com>"},
+ },
+ }
+ for _, test := range tests {
+ got := parseSignatureFromCommitLine(test.line)
+ assert.EqualValues(t, test.want, got)
+ }
+}
diff --git a/modules/git/submodule.go b/modules/git/submodule.go
new file mode 100644
index 0000000..b99c815
--- /dev/null
+++ b/modules/git/submodule.go
@@ -0,0 +1,119 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "path"
+ "regexp"
+ "strings"
+)
+
+var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`)
+
+// SubModule submodule is a reference on git repository
+type SubModule struct {
+ Name string
+ URL string
+}
+
+// SubModuleFile represents a file with submodule type.
+type SubModuleFile struct {
+ *Commit
+
+ refURL string
+ refID string
+}
+
+// NewSubModuleFile create a new submodule file
+func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile {
+ return &SubModuleFile{
+ Commit: c,
+ refURL: refURL,
+ refID: refID,
+ }
+}
+
+func getRefURL(refURL, urlPrefix, repoFullName, sshDomain string) string {
+ if refURL == "" {
+ return ""
+ }
+
+ refURI := strings.TrimSuffix(refURL, ".git")
+
+ prefixURL, _ := url.Parse(urlPrefix)
+ urlPrefixHostname, _, err := net.SplitHostPort(prefixURL.Host)
+ if err != nil {
+ urlPrefixHostname = prefixURL.Host
+ }
+
+ urlPrefix = strings.TrimSuffix(urlPrefix, "/")
+
+ // FIXME: Need to consider branch - which will require changes in modules/git/commit.go:GetSubModules
+ // Relative url prefix check (according to git submodule documentation)
+ if strings.HasPrefix(refURI, "./") || strings.HasPrefix(refURI, "../") {
+ return urlPrefix + path.Clean(path.Join("/", repoFullName, refURI))
+ }
+
+ if !strings.Contains(refURI, "://") {
+ // scp style syntax which contains *no* port number after the : (and is not parsed by net/url)
+ // ex: git@try.gitea.io:go-gitea/gitea
+ match := scpSyntax.FindAllStringSubmatch(refURI, -1)
+ if len(match) > 0 {
+ m := match[0]
+ refHostname := m[2]
+ pth := m[3]
+
+ if !strings.HasPrefix(pth, "/") {
+ pth = "/" + pth
+ }
+
+ if urlPrefixHostname == refHostname || refHostname == sshDomain {
+ return urlPrefix + path.Clean(path.Join("/", pth))
+ }
+ return "http://" + refHostname + pth
+ }
+ }
+
+ ref, err := url.Parse(refURI)
+ if err != nil {
+ return ""
+ }
+
+ refHostname, _, err := net.SplitHostPort(ref.Host)
+ if err != nil {
+ refHostname = ref.Host
+ }
+
+ supportedSchemes := []string{"http", "https", "git", "ssh", "git+ssh"}
+
+ for _, scheme := range supportedSchemes {
+ if ref.Scheme == scheme {
+ if ref.Scheme == "http" || ref.Scheme == "https" {
+ if len(ref.User.Username()) > 0 {
+ return ref.Scheme + "://" + fmt.Sprintf("%v", ref.User) + "@" + ref.Host + ref.Path
+ }
+ return ref.Scheme + "://" + ref.Host + ref.Path
+ } else if urlPrefixHostname == refHostname || refHostname == sshDomain {
+ return urlPrefix + path.Clean(path.Join("/", ref.Path))
+ }
+ return "http://" + refHostname + ref.Path
+ }
+ }
+
+ return ""
+}
+
+// RefURL guesses and returns reference URL.
+func (sf *SubModuleFile) RefURL(urlPrefix, repoFullName, sshDomain string) string {
+ return getRefURL(sf.refURL, urlPrefix, repoFullName, sshDomain)
+}
+
+// RefID returns reference ID.
+func (sf *SubModuleFile) RefID() string {
+ return sf.refID
+}
diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go
new file mode 100644
index 0000000..e05f251
--- /dev/null
+++ b/modules/git/submodule_test.go
@@ -0,0 +1,42 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetRefURL(t *testing.T) {
+ kases := []struct {
+ refURL string
+ prefixURL string
+ parentPath string
+ SSHDomain string
+ expect string
+ }{
+ {"git://github.com/user1/repo1", "/", "user1/repo2", "", "http://github.com/user1/repo1"},
+ {"https://localhost/user1/repo1.git", "/", "user1/repo2", "", "https://localhost/user1/repo1"},
+ {"http://localhost/user1/repo1.git", "/", "owner/reponame", "", "http://localhost/user1/repo1"},
+ {"git@github.com:user1/repo1.git", "/", "owner/reponame", "", "http://github.com/user1/repo1"},
+ {"ssh://git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/zefie/lge_g6_kernel_scripts"},
+ {"git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/2222/zefie/lge_g6_kernel_scripts"},
+ {"git@try.gitea.io:go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"ssh://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"git://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"ssh://git@127.0.0.1:9999/go-gitea/gitea", "https://127.0.0.1:3000/", "go-gitea/sdk", "", "https://127.0.0.1:3000/go-gitea/gitea"},
+ {"https://gitea.com:3000/user1/repo1.git", "https://127.0.0.1:3000/", "user/repo2", "", "https://gitea.com:3000/user1/repo1"},
+ {"https://example.gitea.com/gitea/user1/repo1.git", "https://example.gitea.com/gitea/", "", "user/repo2", "https://example.gitea.com/gitea/user1/repo1"},
+ {"https://username:password@github.com/username/repository.git", "/", "username/repository2", "", "https://username:password@github.com/username/repository"},
+ {"somethingbad", "https://127.0.0.1:3000/go-gitea/gitea", "/", "", ""},
+ {"git@localhost:user/repo", "https://localhost/", "user2/repo1", "", "https://localhost/user/repo"},
+ {"../path/to/repo.git/", "https://localhost/", "user/repo2", "", "https://localhost/user/path/to/repo.git"},
+ {"ssh://git@ssh.gitea.io:2222/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "ssh.gitea.io", "https://try.gitea.io/go-gitea/gitea"},
+ }
+
+ for _, kase := range kases {
+ assert.EqualValues(t, kase.expect, getRefURL(kase.refURL, kase.prefixURL, kase.parentPath, kase.SSHDomain))
+ }
+}
diff --git a/modules/git/tag.go b/modules/git/tag.go
new file mode 100644
index 0000000..04f50e8
--- /dev/null
+++ b/modules/git/tag.go
@@ -0,0 +1,129 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "sort"
+ "strings"
+
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ beginpgp = "\n-----BEGIN PGP SIGNATURE-----\n"
+ endpgp = "\n-----END PGP SIGNATURE-----"
+ beginssh = "\n-----BEGIN SSH SIGNATURE-----\n"
+ endssh = "\n-----END SSH SIGNATURE-----"
+)
+
+// Tag represents a Git tag.
+type Tag struct {
+ Name string
+ ID ObjectID
+ Object ObjectID // The id of this commit object
+ Type string
+ Tagger *Signature
+ Message string
+ Signature *ObjectSignature
+ ArchiveDownloadCount *api.TagArchiveDownloadCount
+}
+
+// Commit return the commit of the tag reference
+func (tag *Tag) Commit(gitRepo *Repository) (*Commit, error) {
+ return gitRepo.getCommit(tag.Object)
+}
+
+// Parse commit information from the (uncompressed) raw
+// data from the commit object.
+// \n\n separate headers from message
+func parseTagData(objectFormat ObjectFormat, data []byte) (*Tag, error) {
+ tag := new(Tag)
+ tag.ID = objectFormat.EmptyObjectID()
+ tag.Object = objectFormat.EmptyObjectID()
+ tag.Tagger = &Signature{}
+ // we now have the contents of the commit object. Let's investigate...
+ nextline := 0
+l:
+ for {
+ eol := bytes.IndexByte(data[nextline:], '\n')
+ switch {
+ case eol > 0:
+ line := data[nextline : nextline+eol]
+ spacepos := bytes.IndexByte(line, ' ')
+ reftype := line[:spacepos]
+ switch string(reftype) {
+ case "object":
+ id, err := NewIDFromString(string(line[spacepos+1:]))
+ if err != nil {
+ return nil, err
+ }
+ tag.Object = id
+ case "type":
+ // A commit can have one or more parents
+ tag.Type = string(line[spacepos+1:])
+ case "tagger":
+ tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(line[spacepos+1:]))
+ }
+ nextline += eol + 1
+ case eol == 0:
+ tag.Message = string(data[nextline+1:])
+ break l
+ default:
+ break l
+ }
+ }
+
+ extractTagSignature := func(signatureBeginMark, signatureEndMark string) (bool, *ObjectSignature, string) {
+ idx := strings.LastIndex(tag.Message, signatureBeginMark)
+ if idx == -1 {
+ return false, nil, ""
+ }
+
+ endSigIdx := strings.Index(tag.Message[idx:], signatureEndMark)
+ if endSigIdx == -1 {
+ return false, nil, ""
+ }
+
+ return true, &ObjectSignature{
+ Signature: tag.Message[idx+1 : idx+endSigIdx+len(signatureEndMark)],
+ Payload: string(data[:bytes.LastIndex(data, []byte(signatureBeginMark))+1]),
+ }, tag.Message[:idx+1]
+ }
+
+ // Try to find an OpenPGP signature
+ found, sig, message := extractTagSignature(beginpgp, endpgp)
+ if !found {
+ // If not found, try an SSH one
+ found, sig, message = extractTagSignature(beginssh, endssh)
+ }
+ // If either is found, update the tag Signature and Message
+ if found {
+ tag.Signature = sig
+ tag.Message = message
+ }
+
+ return tag, nil
+}
+
+type tagSorter []*Tag
+
+func (ts tagSorter) Len() int {
+ return len([]*Tag(ts))
+}
+
+func (ts tagSorter) Less(i, j int) bool {
+ return []*Tag(ts)[i].Tagger.When.After([]*Tag(ts)[j].Tagger.When)
+}
+
+func (ts tagSorter) Swap(i, j int) {
+ []*Tag(ts)[i], []*Tag(ts)[j] = []*Tag(ts)[j], []*Tag(ts)[i]
+}
+
+// sortTagsByTime
+func sortTagsByTime(tags []*Tag) {
+ sorter := tagSorter(tags)
+ sort.Sort(sorter)
+}
diff --git a/modules/git/tag_test.go b/modules/git/tag_test.go
new file mode 100644
index 0000000..8279066
--- /dev/null
+++ b/modules/git/tag_test.go
@@ -0,0 +1,109 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_parseTagData(t *testing.T) {
+ testData := []struct {
+ data []byte
+ tag Tag
+ }{
+ {data: []byte(`object 3b114ab800c6432ad42387ccf6bc8d4388a2885a
+type commit
+tag 1.22.0
+tagger Lucas Michot <lucas@semalead.com> 1484491741 +0100
+
+`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0x3b, 0x11, 0x4a, 0xb8, 0x0, 0xc6, 0x43, 0x2a, 0xd4, 0x23, 0x87, 0xcc, 0xf6, 0xbc, 0x8d, 0x43, 0x88, 0xa2, 0x88, 0x5a},
+ Type: "commit",
+ Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0)},
+ Message: "",
+ Signature: nil,
+ }},
+ {data: []byte(`object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc
+type commit
+tag 1.22.1
+tagger Lucas Michot <lucas@semalead.com> 1484553735 +0100
+
+test message
+o
+
+ono`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0x7c, 0xdf, 0x42, 0xc0, 0xb1, 0xcc, 0x76, 0x3a, 0xb7, 0xe4, 0xc3, 0x3c, 0x47, 0xa2, 0x4e, 0x27, 0xc6, 0x6b, 0xfc, 0xcc},
+ Type: "commit",
+ Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0)},
+ Message: "test message\no\n\nono",
+ Signature: nil,
+ }},
+ {data: []byte(`object d8d1fdb5b20eaca882e34ee510eb55941a242b24
+type commit
+tag v0
+tagger Jane Doe <jane.doe@example.com> 1709146405 +0100
+
+v0
+-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh
+6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7
+5LVB3fV2GmmFDKGB+wCAo=
+-----END SSH SIGNATURE-----
+`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0xd8, 0xd1, 0xfd, 0xb5, 0xb2, 0x0e, 0xac, 0xa8, 0x82, 0xe3, 0x4e, 0xe5, 0x10, 0xeb, 0x55, 0x94, 0x1a, 0x24, 0x2b, 0x24},
+ Type: "commit",
+ Tagger: &Signature{Name: "Jane Doe", Email: "jane.doe@example.com", When: time.Unix(1709146405, 0)},
+ Message: "v0\n",
+ Signature: &ObjectSignature{
+ Signature: `-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh
+6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7
+5LVB3fV2GmmFDKGB+wCAo=
+-----END SSH SIGNATURE-----`,
+ Payload: `object d8d1fdb5b20eaca882e34ee510eb55941a242b24
+type commit
+tag v0
+tagger Jane Doe <jane.doe@example.com> 1709146405 +0100
+
+v0
+`,
+ },
+ }},
+ }
+
+ for _, test := range testData {
+ tag, err := parseTagData(Sha1ObjectFormat, test.data)
+ require.NoError(t, err)
+ assert.EqualValues(t, test.tag.ID, tag.ID)
+ assert.EqualValues(t, test.tag.Object, tag.Object)
+ assert.EqualValues(t, test.tag.Name, tag.Name)
+ assert.EqualValues(t, test.tag.Message, tag.Message)
+ assert.EqualValues(t, test.tag.Type, tag.Type)
+ if test.tag.Signature != nil && assert.NotNil(t, tag.Signature) {
+ assert.EqualValues(t, test.tag.Signature.Signature, tag.Signature.Signature)
+ assert.EqualValues(t, test.tag.Signature.Payload, tag.Signature.Payload)
+ } else {
+ assert.Nil(t, tag.Signature)
+ }
+ if test.tag.Tagger != nil && assert.NotNil(t, tag.Tagger) {
+ assert.EqualValues(t, test.tag.Tagger.Name, tag.Tagger.Name)
+ assert.EqualValues(t, test.tag.Tagger.Email, tag.Tagger.Email)
+ assert.EqualValues(t, test.tag.Tagger.When.Unix(), tag.Tagger.When.Unix())
+ } else {
+ assert.Nil(t, tag.Tagger)
+ }
+ }
+}
diff --git a/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG
new file mode 100644
index 0000000..ec4d890
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG
@@ -0,0 +1,3 @@
+Add some test files for GetLanguageStats
+
+Signed-off-by: Andrew Thornton <art27@cantab.net>
diff --git a/modules/git/tests/repos/language_stats_repo/HEAD b/modules/git/tests/repos/language_stats_repo/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/language_stats_repo/config b/modules/git/tests/repos/language_stats_repo/config
new file mode 100644
index 0000000..a4ef456
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/config
@@ -0,0 +1,5 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ logallrefupdates = true
diff --git a/modules/git/tests/repos/language_stats_repo/description b/modules/git/tests/repos/language_stats_repo/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/language_stats_repo/index b/modules/git/tests/repos/language_stats_repo/index
new file mode 100644
index 0000000..e6c0223
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/index
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/info/exclude b/modules/git/tests/repos/language_stats_repo/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/language_stats_repo/logs/HEAD b/modules/git/tests/repos/language_stats_repo/logs/HEAD
new file mode 100644
index 0000000..9cedbb6
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats
+8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push
diff --git a/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master
new file mode 100644
index 0000000..9cedbb6
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats
+8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push
diff --git a/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c
new file mode 100644
index 0000000..3c55bab
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb
new file mode 100644
index 0000000..947feec
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97 b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97
new file mode 100644
index 0000000..9ce337e
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085 b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085
new file mode 100644
index 0000000..ff3b642
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d
new file mode 100644
index 0000000..b71abc1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e
new file mode 100644
index 0000000..5c2485d
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc
new file mode 100644
index 0000000..873cb71
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd
new file mode 100644
index 0000000..f89ecb7
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3 b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3
new file mode 100644
index 0000000..0219c2d
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299 b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299
new file mode 100644
index 0000000..adc50f2
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb
new file mode 100644
index 0000000..9d4d4b1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/refs/heads/master b/modules/git/tests/repos/language_stats_repo/refs/heads/master
new file mode 100644
index 0000000..e89143e
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/refs/heads/master
@@ -0,0 +1 @@
+341fca5b5ea3de596dc483e54c2db28633cd2f97
diff --git a/modules/git/tests/repos/repo1_bare/HEAD b/modules/git/tests/repos/repo1_bare/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo1_bare/config b/modules/git/tests/repos/repo1_bare/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/modules/git/tests/repos/repo1_bare/description b/modules/git/tests/repos/repo1_bare/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo1_bare/index b/modules/git/tests/repos/repo1_bare/index
new file mode 100644
index 0000000..65d6751
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/index
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/info/exclude b/modules/git/tests/repos/repo1_bare/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo1_bare/logs/HEAD b/modules/git/tests/repos/repo1_bare/logs/HEAD
new file mode 100644
index 0000000..46da5fe
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/logs/HEAD
@@ -0,0 +1,2 @@
+37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push
+feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push
diff --git a/modules/git/tests/repos/repo1_bare/logs/refs/heads/master b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master
new file mode 100644
index 0000000..46da5fe
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master
@@ -0,0 +1,2 @@
+37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push
+feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push
diff --git a/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60
new file mode 100644
index 0000000..11de5ad
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0
new file mode 100644
index 0000000..3541cd1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310
new file mode 100644
index 0000000..8db3c79
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6
new file mode 100644
index 0000000..45e014e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb
new file mode 100644
index 0000000..fb50b65
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b
new file mode 100644
index 0000000..8c0b1b3
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02 b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02
new file mode 100644
index 0000000..05dc472
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3
new file mode 100644
index 0000000..e22e656
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3
@@ -0,0 +1 @@
+x…ÎÁ Â0 @QΙ" €â¸il që 8Ž¨ÔB‚Ôñ©X€óÿ‡'¯u»ÆSoª6*摨"Ž,É“æTsI\I+r,|2[júì–…“V*…u>KT?P4ÂèRt@Á¤O¼šö´n‹Úû[›½Þ,À\`oÏŽœ3òõ£þ]ÍTz…Kß»ùe;ƒ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32 b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32
new file mode 100644
index 0000000..7779599
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e
new file mode 100644
index 0000000..3e46ba4
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8
new file mode 100644
index 0000000..3a5c6c1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5
new file mode 100644
index 0000000..29f2d4f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64 b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64
new file mode 100644
index 0000000..c96b843
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123
new file mode 100644
index 0000000..3658e95
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123
@@ -0,0 +1 @@
+xŽKN1 YçÞ#ç3 !Øp.ØÎL‹™J›·§á,kñêû}5 PlªB÷5sKÔH|>ŸdíKÌKl kì% û¬S7ƒÜ›ä¢e¡Ó¢™c¥Î‰±çÆH‡§Señ®~ÙuLxŸëocì Óeµ—ý:D¾7µÓ˜—gð‰¢_Bñ=":þËüÝüSà^ETàø™·uûp?ƒ6M^ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702
new file mode 100644
index 0000000..c3d484a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a
new file mode 100644
index 0000000..ee2652b
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
new file mode 100644
index 0000000..adf6411
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f
new file mode 100644
index 0000000..b969059
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca
new file mode 100644
index 0000000..1629271
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658
new file mode 100644
index 0000000..234d41b
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658
@@ -0,0 +1,3 @@
+x…Ž;
+1@­sŠ¹€:Éf7ÁÂ#x€ÉL‚‚û!FØ㻈½Õ+Þ+žÌãøhàÐíZÍb—H¼²²ö„‘J<J™…B
+VK6 ×<5ˆJ®õ½)J1Iú‚)¤d#1ê Tœáw»Ï®+Ë3Ãí•+œÎ`{»å;„=FD#ß¡¶Ù¿©¹¨Bª<ÉÝ<´µ™3ã>= \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315
new file mode 100644
index 0000000..15b958a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e
new file mode 100644
index 0000000..eb0ad47
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375
new file mode 100644
index 0000000..7d217a7
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1
new file mode 100644
index 0000000..5b2cfb2
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1
@@ -0,0 +1 @@
+xŽ1nÃ0 E3ëÜ ”-™&ÉÒô’H&F+Õ¡·oÚ#døÃÞÃ/õñX: ÁïzS…i£±Zâb1“Ø”Saö”gÔ@ÄFÝ35];̈“'Ɇ%sD’„5ŽôÚìÉØR,èÒw¿Ö_mÙ೶­kƒCÑþ²ôÓv­"?«ö}m—#ø8"Í#|xDtåÿæŸófÀET ·zÓîËzÛÜ/—þN° \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8
new file mode 100644
index 0000000..113089d
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0
new file mode 100644
index 0000000..808fe6e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631
new file mode 100644
index 0000000..6a194fb
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe
new file mode 100644
index 0000000..8602ab5
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2
new file mode 100644
index 0000000..431a481
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b
new file mode 100644
index 0000000..e198e76
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653
new file mode 100644
index 0000000..6bb6a25
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56
new file mode 100644
index 0000000..ae6c93a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185
new file mode 100644
index 0000000..8a263d0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185
@@ -0,0 +1,2 @@
+x…ÎM
+1 @a×=E. ¤“¦Ó‚.<‚HÛ”œj…9¾â\¿oñò:Ï6ºCoª@è2ûDI+QA©š[V H9P,R %³IÓ¥Cä”\¡è]P•¶èKE+~°™ ƒ'ñLFÞ}ZÜv™·§Âý¥ ΰliddáˆÑäßPÿÖ¿Ô\KÔdÉ“=õ½›ܲ:c \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13 b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13
new file mode 100644
index 0000000..35d27dc
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351
new file mode 100644
index 0000000..02fe24f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3
new file mode 100644
index 0000000..aacc5ef
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24
new file mode 100644
index 0000000..6c2e007
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3
new file mode 100644
index 0000000..57c5d7c
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c
new file mode 100644
index 0000000..d4c2138
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c
@@ -0,0 +1,4 @@
+x¥ŽM
+Â0F]ç³ëB&&m"ž@\¹Of¦6ÐHG¥··ô
+~Ë·xïÃy³€Ñþ …Œ?[—Œ¶èBÓ&
+H<bÛyß™NGt­åÚ¨ø–~.ð"å1xÄIx`þÀå•å&=㚸,}¤ù{šX® ó¶ p¬·)ÜãÂjÔ}^ 1AZ¡ÚÀ´3¦,•ú½ÀI0 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612 b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612
new file mode 100644
index 0000000..93f1525
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d
new file mode 100644
index 0000000..1152b25
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da
new file mode 100644
index 0000000..d29ca5a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449
new file mode 100644
index 0000000..08245d0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930
new file mode 100644
index 0000000..6a412c7
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2 b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2
new file mode 100644
index 0000000..95edd9a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/pulls/1.patch b/modules/git/tests/repos/repo1_bare/pulls/1.patch
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/pulls/1.patch
diff --git a/modules/git/tests/repos/repo1_bare/pulls/2.patch b/modules/git/tests/repos/repo1_bare/pulls/2.patch
new file mode 100644
index 0000000..caab605
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/pulls/2.patch
@@ -0,0 +1,39 @@
+From 6e8e2a6f9efd71dbe6917816343ed8415ad696c3 Mon Sep 17 00:00:00 2001
+From: 99rgosse <renaud@mycompany.com>
+Date: Fri, 26 Mar 2021 12:44:22 +0000
+Subject: [PATCH] Update gitea_import_actions.py
+
+---
+ gitea_import_actions.py | 6 +++---
+ 1 file changed, 3 insertions(+), 3 deletions(-)
+
+diff --git a/gitea_import_actions.py b/gitea_import_actions.py
+index f0d72cd..7b31963 100644
+--- a/gitea_import_actions.py
++++ b/gitea_import_actions.py
+@@ -3,14 +3,14 @@
+ # git log --pretty=format:'%H,%at,%s' --date=default > /tmp/commit.log
+ # to get the commits logfile for a repository
+
+-import mysql.connector as mariadb
++import psycopg2
+
+ # set the following variables to fit your need...
+ USERID = 1
+ REPOID = 1
+ BRANCH = "master"
+
+-mydb = mariadb.connect(
++mydb = psycopg2.connect(
+ host="localhost",
+ user="user",
+ passwd="password",
+@@ -31,4 +31,4 @@ with open("/tmp/commit.log") as f:
+
+ mydb.commit()
+
+-print("actions inserted.")
+\ No newline at end of file
++print("actions inserted.")
+--
+GitLab
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch1 b/modules/git/tests/repos/repo1_bare/refs/heads/branch1
new file mode 100644
index 0000000..eb33bd0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch1
@@ -0,0 +1 @@
+2839944139e0de9737a044f78b0e4b40d989a9e3
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch2 b/modules/git/tests/repos/repo1_bare/refs/heads/branch2
new file mode 100644
index 0000000..0475e61
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch2
@@ -0,0 +1 @@
+5c80b0245c1c6f8343fa418ec374b13b5d4ee658
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/master b/modules/git/tests/repos/repo1_bare/refs/heads/master
new file mode 100644
index 0000000..9b0de22
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/master
@@ -0,0 +1 @@
+ce064814f4a0d337b333e646ece456cd39fab612
diff --git a/modules/git/tests/repos/repo1_bare/refs/notes/commits b/modules/git/tests/repos/repo1_bare/refs/notes/commits
new file mode 100644
index 0000000..c88ca21
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/notes/commits
@@ -0,0 +1 @@
+ca6b5ddf303169a72d2a2971acde4f6eea194e5c
diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag
new file mode 100644
index 0000000..3998a68
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag
@@ -0,0 +1 @@
+36f97d9a96457e2bab511db30fe2db03893ebc64
diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/test b/modules/git/tests/repos/repo1_bare/refs/tags/test
new file mode 100644
index 0000000..ee31172
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/tags/test
@@ -0,0 +1 @@
+3ad28a9149a2864384548f3d17ed7f38014c9e8a
diff --git a/modules/git/tests/repos/repo1_bare_sha256/HEAD b/modules/git/tests/repos/repo1_bare_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo1_bare_sha256/config b/modules/git/tests/repos/repo1_bare_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo1_bare_sha256/description b/modules/git/tests/repos/repo1_bare_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/exclude b/modules/git/tests/repos/repo1_bare_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/refs b/modules/git/tests/repos/repo1_bare_sha256/info/refs
new file mode 100644
index 0000000..b4de954
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/info/refs
@@ -0,0 +1,7 @@
+42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1
+5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main
+29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/tags/signed-tag^{}
+171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test
+6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1 refs/tags/test^{}
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..2985d3e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs
new file mode 100644
index 0000000..c2d1bb8
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
+
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap
new file mode 100644
index 0000000..535ba16
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx
new file mode 100644
index 0000000..ab45b6f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
new file mode 100644
index 0000000..c77bf20
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev
new file mode 100644
index 0000000..d24fd8e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/packed-refs b/modules/git/tests/repos/repo1_bare_sha256/packed-refs
new file mode 100644
index 0000000..36c92ce
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/packed-refs
@@ -0,0 +1,8 @@
+# pack-refs with: peeled fully-peeled sorted
+42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1
+5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main
+29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag
+^9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0
+171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test
+^6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1
diff --git a/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main
new file mode 100644
index 0000000..b09fd5c
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main
@@ -0,0 +1 @@
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0
diff --git a/modules/git/tests/repos/repo2_empty/HEAD b/modules/git/tests/repos/repo2_empty/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo2_empty/config b/modules/git/tests/repos/repo2_empty/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/modules/git/tests/repos/repo2_empty/description b/modules/git/tests/repos/repo2_empty/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo2_empty/info/exclude b/modules/git/tests/repos/repo2_empty/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep
diff --git a/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG
new file mode 100644
index 0000000..0cfbf08
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG
@@ -0,0 +1 @@
+2
diff --git a/modules/git/tests/repos/repo3_notes/HEAD b/modules/git/tests/repos/repo3_notes/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo3_notes/config b/modules/git/tests/repos/repo3_notes/config
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/modules/git/tests/repos/repo3_notes/description b/modules/git/tests/repos/repo3_notes/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo3_notes/index b/modules/git/tests/repos/repo3_notes/index
new file mode 100644
index 0000000..783158b
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/index
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/logs/HEAD b/modules/git/tests/repos/repo3_notes/logs/HEAD
new file mode 100644
index 0000000..4bd0a61
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1
+ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2
diff --git a/modules/git/tests/repos/repo3_notes/logs/refs/heads/master b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master
new file mode 100644
index 0000000..4bd0a61
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1
+ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2
diff --git a/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6 b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6
new file mode 100644
index 0000000..96fb749
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef
new file mode 100644
index 0000000..71cff17
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4 b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4
new file mode 100644
index 0000000..8f13b31
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4
@@ -0,0 +1,3 @@
+xŽ;Â0 @™s
+ïH•ã&v*!ÄÄÈœ4Jô£(p~
+G`|oxziç©‘;´š3Ð –ÂÈÞ÷6  œ$`¦"NRäѺXla³iÍKƒ¨¨åÞ÷4rò$§\P0"yÌ£PQ'F_í±V¸NÏiƒ›¾µ*œÊ—ºåG—û¬Ó³Kë|ëY„eÀŽHˆf·ûfË ™ÜãEm \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02 b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02
new file mode 100644
index 0000000..3d522eb
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
new file mode 100644
index 0000000..b17dfe3
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544 b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544
new file mode 100644
index 0000000..b09d3a2
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37 b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37
new file mode 100644
index 0000000..0d317cb
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37
@@ -0,0 +1 @@
+x;Â0©}Ší‘"Ç¿u$„RQr‡•ÙK1F–Éù1nfŠ÷R-%w˜Ñzcá´{ä%7À¢h#Ñx¶ˆÉQ¤àéfXÑ»?jƒKÞò ®´S#8ÉצçÏÖ{¡¼M©–3Ì> †¨…£6Z«QÇmç¿Ôÿ8 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47 b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47
new file mode 100644
index 0000000..c21f2b2
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225 b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225
new file mode 100644
index 0000000..f5a8caa
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
new file mode 100644
index 0000000..4b1baef
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907 b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907
new file mode 100644
index 0000000..dc2af77
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e
new file mode 100644
index 0000000..6372ff1
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/refs/heads/master b/modules/git/tests/repos/repo3_notes/refs/heads/master
new file mode 100644
index 0000000..e96af8d
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/refs/heads/master
@@ -0,0 +1 @@
+3e668dbfac39cbc80a9ff9c61eb565d944453ba4 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/refs/notes/commits b/modules/git/tests/repos/repo3_notes/refs/notes/commits
new file mode 100644
index 0000000..74e3d3a
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/refs/notes/commits
@@ -0,0 +1 @@
+654c8b6b63c08bf37f638d3f521626b7fbbd4d37 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo4_commitsbetween/HEAD b/modules/git/tests/repos/repo4_commitsbetween/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo4_commitsbetween/config b/modules/git/tests/repos/repo4_commitsbetween/config
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD
new file mode 100644
index 0000000..24cc684
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD
@@ -0,0 +1,4 @@
+0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2
+78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new
diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main
new file mode 100644
index 0000000..24cc684
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main
@@ -0,0 +1,4 @@
+0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2
+78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea
new file mode 100644
index 0000000..5b26f8b
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
new file mode 100644
index 0000000..b17dfe3
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344 b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344
new file mode 100644
index 0000000..6d23de0
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344
@@ -0,0 +1,3 @@
+xÎM
+Â0@a×=Eö‚̤Iš€ˆà²àÂ$“Zl©ñþþÁ|¼Gµ”¹)îmÌŠuOä"€·€‚&`ã8GtÀIœ7Ý#n¼6%™09´)“8ë“F—(hl™Ò@ƒïâ«MuSãÕ\Æþ¦Ž1—y=×%?iªu™"Ý…O
+þDm½ÚƒèèwØøûź{p‹C_ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca
new file mode 100644
index 0000000..d5c554a
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5 b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5
new file mode 100644
index 0000000..26ed785
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098 b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098
new file mode 100644
index 0000000..8060b57
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
new file mode 100644
index 0000000..4b1baef
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684 b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684
new file mode 100644
index 0000000..0a70530
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78 b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78
new file mode 100644
index 0000000..2e6d945
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main
new file mode 100644
index 0000000..9e1b981
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main
@@ -0,0 +1 @@
+a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca
diff --git a/modules/git/tests/repos/repo5_pulls/HEAD b/modules/git/tests/repos/repo5_pulls/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo5_pulls/config b/modules/git/tests/repos/repo5_pulls/config
new file mode 100644
index 0000000..0a0ad6d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+[receive]
+ advertisePushOptions = true
diff --git a/modules/git/tests/repos/repo5_pulls/description b/modules/git/tests/repos/repo5_pulls/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo5_pulls/info/exclude b/modules/git/tests/repos/repo5_pulls/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df
new file mode 100644
index 0000000..90464be
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf
new file mode 100644
index 0000000..cf9d59f
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af
new file mode 100644
index 0000000..efc69b1
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af
@@ -0,0 +1 @@
+x%Ž½nÃ0 „;ë)¸0H…ú1 P]Úô(‘F2¸Tåýk·7|¸wu]–{OôÒ›„H¨²p®œ8³$A”1¦"\¢ªaÂRf÷fß4Û ‡Ù#ZL:JÊ\-„¢#fO2s°¢Nžý¶6èöÓ¯ÓçN»;ïv¼Å#úè 3p“«׺5ˆpÚy^‹µåyÔþL)xÚÛ¼s_•nð1]ÞÞ§aÑ_)@X \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1
new file mode 100644
index 0000000..74e848f
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab
new file mode 100644
index 0000000..d6e616d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2
new file mode 100644
index 0000000..271cffb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2
@@ -0,0 +1,2 @@
+x­MNÄ0 …Y÷Öl„œ'„€ ‰i%ú£4ÜŸÄ Ø<=ù}~²ó2MccÜM«" ¢ÈhÖ¬zŽ±÷)q(•CRIŠO¤¸tk¬27Ƚ1=²GrL&]ØYBFtÚ'&o„?^¸/–u‰”´ÕèÑѾ®‚*ÄL˜­ØÝ›Òů6,¶\ǵÅO©íöô
+ï²5øؤžî#xjûìå‡CžA9ƒVyB÷¨»üóc“ÿiëÞ¤^Rsà<Åmo>Ã8·Ž.kly¸¨îC©iè \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd
new file mode 100644
index 0000000..0e2dc87
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd
@@ -0,0 +1,2 @@
+x­ŽAJAE]÷)Š¬"‚VwWÏt E²Äz€NU5Ì$ôTö9ˆ¸ò&Þ$'1Ñ+¸y|þƒÏçíf³6=^XS…NpEÌ…"ÍRÌ1v>W–(Ò®•gD©ÞíJÓÁ@%W’PKZ
+Øc—2ŠŸùšD2)r¬®ìímÛ`ä¶ÞYy×fÓɼèhð:j›\Þü)˜Û©»=ãúŒø."ù>ùWÿ~6ýŸ5w<|>>Ü/Ÿž—ÇÃ| ¢mp?ˆXó \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f
new file mode 100644
index 0000000..33d2a21
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f
new file mode 100644
index 0000000..d64847c
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f
@@ -0,0 +1,3 @@
+x­ŽAJAE]÷)Š¬!V×ÌtMƒˆ"YâF=@uw5Ì$ô”ûD\yo’“hô
+nÞâ?ø¼¼ÝlÖÄxbMd ,ƒTŸ˜C7f%äÈuÄ”¼PŒÜ3Jr;i:ÔŽJ,µ`”€5øP)úa¬Ì”µ”1Æž
+9y³—mƒ9·õÎäU›.nàIgƒçYÛâìâOÁ¥ýl×G,¸:ì=÷q€s$D—›MÿçÍö÷w·«‡ÇÕaÿ_ŸSÑ6¹o9X‚ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640
new file mode 100644
index 0000000..9cd9d00
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/info/packs b/modules/git/tests/repos/repo5_pulls/objects/info/packs
new file mode 100644
index 0000000..8bbc848
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
+
diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx
new file mode 100644
index 0000000..b66df23
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
new file mode 100644
index 0000000..a5dfc5e
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/packed-refs b/modules/git/tests/repos/repo5_pulls/packed-refs
new file mode 100644
index 0000000..d0012b5
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+c83380d7056593c51a699d12b9c00627bd5743e9 refs/heads/test-patch-1
+c83380d7056593c51a699d12b9c00627bd5743e9 refs/pull/1/head
+111cac04bd7d20301964e27a93698aabb5781b80 refs/pull/1/merge
+72866af952e98d02a73003501836074b286a78f6 refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master b/modules/git/tests/repos/repo5_pulls/refs/heads/master
new file mode 100644
index 0000000..9a8e3b2
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master
@@ -0,0 +1 @@
+d8e0bbb45f200e67d9a784ce55bd90821af45ebd
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone
new file mode 100644
index 0000000..9a8e3b2
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone
@@ -0,0 +1 @@
+d8e0bbb45f200e67d9a784ce55bd90821af45ebd
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1
new file mode 100644
index 0000000..d8b26cb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1
@@ -0,0 +1 @@
+58a4bcc53ac13e7ff76127e0fb518b5262bf09af
diff --git a/modules/git/tests/repos/repo5_pulls/refs/pull/4/head b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head
new file mode 100644
index 0000000..d8b26cb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head
@@ -0,0 +1 @@
+58a4bcc53ac13e7ff76127e0fb518b5262bf09af
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/HEAD b/modules/git/tests/repos/repo5_pulls_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/config b/modules/git/tests/repos/repo5_pulls_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/description b/modules/git/tests/repos/repo5_pulls_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/info/refs b/modules/git/tests/repos/repo5_pulls_sha256/info/refs
new file mode 100644
index 0000000..454e45d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/info/refs
@@ -0,0 +1,4 @@
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone
+7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1
+1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..8e5ef41
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs
new file mode 100644
index 0000000..6f51e7b
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
+
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap
new file mode 100644
index 0000000..38fca6e
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx
new file mode 100644
index 0000000..fd43d04
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
new file mode 100644
index 0000000..689318d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev
new file mode 100644
index 0000000..c0bac95
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/packed-refs b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs
new file mode 100644
index 0000000..1525083
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone
+7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1
+1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main
new file mode 100644
index 0000000..9b32e79
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main
@@ -0,0 +1 @@
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b
diff --git a/modules/git/tests/repos/repo6_blame/HEAD b/modules/git/tests/repos/repo6_blame/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo6_blame/config b/modules/git/tests/repos/repo6_blame/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c
new file mode 100644
index 0000000..6cde910
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1
new file mode 100644
index 0000000..b8db01d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376
new file mode 100644
index 0000000..6c0ae47
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9
new file mode 100644
index 0000000..5c2b564
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7
new file mode 100644
index 0000000..3c64718
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044
new file mode 100644
index 0000000..847b7bc
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93
new file mode 100644
index 0000000..206ef1e
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421
new file mode 100644
index 0000000..bb26889
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8
new file mode 100644
index 0000000..1653ed9
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/refs/heads/master b/modules/git/tests/repos/repo6_blame/refs/heads/master
new file mode 100644
index 0000000..01c9922
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/refs/heads/master
@@ -0,0 +1 @@
+544d8f7a3b15927cddf2299b4b562d6ebd71b6a7
diff --git a/modules/git/tests/repos/repo6_blame_sha256/HEAD b/modules/git/tests/repos/repo6_blame_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/config b/modules/git/tests/repos/repo6_blame_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo6_blame_sha256/description b/modules/git/tests/repos/repo6_blame_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/exclude b/modules/git/tests/repos/repo6_blame_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/refs b/modules/git/tests/repos/repo6_blame_sha256/info/refs
new file mode 100644
index 0000000..bee6d1d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/info/refs
@@ -0,0 +1 @@
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..f963aa0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs
new file mode 100644
index 0000000..73744cf
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
+
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap
new file mode 100644
index 0000000..c34487c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx
new file mode 100644
index 0000000..faaee22
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
new file mode 100644
index 0000000..626f081
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev
new file mode 100644
index 0000000..5617555
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/packed-refs b/modules/git/tests/repos/repo6_blame_sha256/packed-refs
new file mode 100644
index 0000000..6442692
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/packed-refs
@@ -0,0 +1,2 @@
+# pack-refs with: peeled fully-peeled sorted
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main
new file mode 100644
index 0000000..829662c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main
@@ -0,0 +1 @@
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3
diff --git a/modules/git/tests/repos/repo6_merge/HEAD b/modules/git/tests/repos/repo6_merge/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699 b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699
new file mode 100644
index 0000000..0778a1c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc
new file mode 100644
index 0000000..c71794f
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576 b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576
new file mode 100644
index 0000000..365f368
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411 b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411
new file mode 100644
index 0000000..83890b5
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411
@@ -0,0 +1,2 @@
+xÍM
+1 †a×=Eö‚$µÍLAÄ«ô'Á‡‘iæþR½€›g÷~_ÝÖu1 ˜N¶‹@mZ)g2…D‘j™Š*_“fŒÌs ¥4ïòaÏm“np>—Áˆç€Ì>!œÑ#ºú½1ù;p]ìxÿæuyIwÑN4à \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439 b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439
new file mode 100644
index 0000000..582d98c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439
@@ -0,0 +1,2 @@
+xÎM
+Â0†a×9Åì™üM2 âUšæ ¬•=¿Ô¸yv/¼ó¶®Ë Çî0:@±ò$±UѬ«.—[Ê>« ”l“‹IÌsêx ò©ú8'T¯°R¹Ä¤S,ª±$x. Öšé=n[§× óîuç´s!+9°ˆ÷BGvÌfþm ü˜Žuû€Úr‡ùÿÁ>® \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b
new file mode 100644
index 0000000..d7faff6
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597 b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597
new file mode 100644
index 0000000..8ac2814
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2 b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2
new file mode 100644
index 0000000..c0f6b13
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2
@@ -0,0 +1,5 @@
+xÎM
+1 @a×=Eö‚¤?iñ*mšÁÇ‘™xOàæÛ=x².Ëlà™O¶©R¢Z80ŠÄ\[í*Ú%µb
+ƒ&qï¶éË –IŠŽÈšÔç
+7êÌÔ‹F쨜¼wícuÓÝàzx?¸ÜÀçš0ç
+œ1 :ùm™þ¸6LóSÝÀí>& \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a
new file mode 100644
index 0000000..edef2a7
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8 b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8
new file mode 100644
index 0000000..353d86c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831 b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831
new file mode 100644
index 0000000..5449d72
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2 b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2
new file mode 100644
index 0000000..030eb98
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77 b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77
new file mode 100644
index 0000000..867e4c0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2 b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2
new file mode 100644
index 0000000..52d300c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92 b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92
new file mode 100644
index 0000000..112998d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/main b/modules/git/tests/repos/repo6_merge/refs/heads/main
new file mode 100644
index 0000000..adf9e86
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/main
@@ -0,0 +1 @@
+022f4ce6214973e018f02bf363bf8a2e3691f699
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file
new file mode 100644
index 0000000..035ad11
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file
@@ -0,0 +1 @@
+ae4b035e7c4afbc000576cee3f713ea0c2f1e1e2
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file
new file mode 100644
index 0000000..f0d5105
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file
@@ -0,0 +1 @@
+d1792641396ff7630d35fbb0b74b86b0c71bca77
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file
new file mode 100644
index 0000000..ada3c8b
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file
@@ -0,0 +1 @@
+38ec3e0cdc88bde01014bda4a5dd9fc835f41439
diff --git a/modules/git/tests/repos/repo6_merge_sha256/HEAD b/modules/git/tests/repos/repo6_merge_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_merge_sha256/config b/modules/git/tests/repos/repo6_merge_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo6_merge_sha256/description b/modules/git/tests/repos/repo6_merge_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/exclude b/modules/git/tests/repos/repo6_merge_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/refs b/modules/git/tests/repos/repo6_merge_sha256/info/refs
new file mode 100644
index 0000000..7dae8a1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/info/refs
@@ -0,0 +1,4 @@
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main
+b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file
+ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file
+da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..9806847
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs
new file mode 100644
index 0000000..f3cf819
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs
@@ -0,0 +1,3 @@
+P pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
+P pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
+
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap
new file mode 100644
index 0000000..d1624a0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx
new file mode 100644
index 0000000..09b897d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
new file mode 100644
index 0000000..3b406be
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev
new file mode 100644
index 0000000..4a695fc
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx
new file mode 100644
index 0000000..3b58342
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes
new file mode 100644
index 0000000..a669a06
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
new file mode 100644
index 0000000..a28808e
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev
new file mode 100644
index 0000000..c09bb32
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/packed-refs b/modules/git/tests/repos/repo6_merge_sha256/packed-refs
new file mode 100644
index 0000000..b906893
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main
+b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file
+ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file
+da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file
diff --git a/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main
new file mode 100644
index 0000000..c8c0292
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main
@@ -0,0 +1 @@
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1
diff --git a/modules/git/tree.go b/modules/git/tree.go
new file mode 100644
index 0000000..5b06cbf
--- /dev/null
+++ b/modules/git/tree.go
@@ -0,0 +1,178 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "strings"
+)
+
+// Tree represents a flat directory listing.
+type Tree struct {
+ ID ObjectID
+ ResolvedID ObjectID
+ repo *Repository
+
+ // parent tree
+ ptree *Tree
+
+ entries Entries
+ entriesParsed bool
+
+ entriesRecursive Entries
+ entriesRecursiveParsed bool
+}
+
+// NewTree create a new tree according the repository and tree id
+func NewTree(repo *Repository, id ObjectID) *Tree {
+ return &Tree{
+ ID: id,
+ repo: repo,
+ }
+}
+
+// ListEntries returns all entries of current tree.
+func (t *Tree) ListEntries() (Entries, error) {
+ if t.entriesParsed {
+ return t.entries, nil
+ }
+
+ if t.repo != nil {
+ wr, rd, cancel, err := t.repo.CatFileBatch(t.repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(t.ID.String() + "\n"))
+ _, typ, sz, err := ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+ if typ == "commit" {
+ treeID, err := ReadTreeID(rd, sz)
+ if err != nil && err != io.EOF {
+ return nil, err
+ }
+ _, _ = wr.Write([]byte(treeID + "\n"))
+ _, typ, sz, err = ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if typ == "tree" {
+ t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz)
+ if err != nil {
+ return nil, err
+ }
+ t.entriesParsed = true
+ return t.entries, nil
+ }
+
+ // Not a tree just use ls-tree instead
+ if err := DiscardFull(rd, sz+1); err != nil {
+ return nil, err
+ }
+ }
+
+ stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-l").AddDynamicArguments(t.ID.String()).RunStdBytes(&RunOpts{Dir: t.repo.Path})
+ if runErr != nil {
+ if strings.Contains(runErr.Error(), "fatal: Not a valid object name") || strings.Contains(runErr.Error(), "fatal: not a tree object") {
+ return nil, ErrNotExist{
+ ID: t.ID.String(),
+ }
+ }
+ return nil, runErr
+ }
+
+ var err error
+ t.entries, err = parseTreeEntries(stdout, t)
+ if err == nil {
+ t.entriesParsed = true
+ }
+
+ return t.entries, err
+}
+
+// listEntriesRecursive returns all entries of current tree recursively including all subtrees
+// extraArgs could be "-l" to get the size, which is slower
+func (t *Tree) listEntriesRecursive(extraArgs TrustedCmdArgs) (Entries, error) {
+ if t.entriesRecursiveParsed {
+ return t.entriesRecursive, nil
+ }
+
+ stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-t", "-r").
+ AddArguments(extraArgs...).
+ AddDynamicArguments(t.ID.String()).
+ RunStdBytes(&RunOpts{Dir: t.repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ var err error
+ t.entriesRecursive, err = parseTreeEntries(stdout, t)
+ if err == nil {
+ t.entriesRecursiveParsed = true
+ }
+
+ return t.entriesRecursive, err
+}
+
+// ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size
+func (t *Tree) ListEntriesRecursiveFast() (Entries, error) {
+ return t.listEntriesRecursive(nil)
+}
+
+// ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees, with size
+func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
+ return t.listEntriesRecursive(TrustedCmdArgs{"--long"})
+}
+
+// SubTree get a sub tree by the sub dir path
+func (t *Tree) SubTree(rpath string) (*Tree, error) {
+ if len(rpath) == 0 {
+ return t, nil
+ }
+
+ paths := strings.Split(rpath, "/")
+ var (
+ err error
+ g = t
+ p = t
+ te *TreeEntry
+ )
+ for _, name := range paths {
+ te, err = p.GetTreeEntryByPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ g, err = t.repo.getTree(te.ID)
+ if err != nil {
+ return nil, err
+ }
+ g.ptree = p
+ p = g
+ }
+ return g, nil
+}
+
+// LsTree checks if the given filenames are in the tree
+func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error) {
+ cmd := NewCommand(repo.Ctx, "ls-tree", "-z", "--name-only").
+ AddDashesAndList(append([]string{ref}, filenames...)...)
+
+ res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ filelist := make([]string, 0, len(filenames))
+ for _, line := range bytes.Split(res, []byte{'\000'}) {
+ filelist = append(filelist, string(line))
+ }
+
+ return filelist, err
+}
diff --git a/modules/git/tree_blob.go b/modules/git/tree_blob.go
new file mode 100644
index 0000000..df339f6
--- /dev/null
+++ b/modules/git/tree_blob.go
@@ -0,0 +1,81 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path"
+ "strings"
+)
+
+// GetTreeEntryByPath get the tree entries according the sub dir
+func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
+ if len(relpath) == 0 {
+ return &TreeEntry{
+ ptree: t,
+ ID: t.ID,
+ name: "",
+ fullName: "",
+ entryMode: EntryModeTree,
+ }, nil
+ }
+
+ // FIXME: This should probably use git cat-file --batch to be a bit more efficient
+ relpath = path.Clean(relpath)
+ parts := strings.Split(relpath, "/")
+ var err error
+ tree := t
+ for i, name := range parts {
+ if i == len(parts)-1 {
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+ for _, v := range entries {
+ if v.Name() == name {
+ return v, nil
+ }
+ }
+ } else {
+ tree, err = tree.SubTree(name)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ return nil, ErrNotExist{"", relpath}
+}
+
+// GetBlobByPath get the blob object according the path
+func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) {
+ entry, err := t.GetTreeEntryByPath(relpath)
+ if err != nil {
+ return nil, err
+ }
+
+ if !entry.IsDir() && !entry.IsSubModule() {
+ return entry.Blob(), nil
+ }
+
+ return nil, ErrNotExist{"", relpath}
+}
+
+// GetBlobByFoldedPath returns the blob object at relpath, regardless of the
+// case of relpath. If there are multiple files with the same case-insensitive
+// name, the first one found will be returned.
+func (t *Tree) GetBlobByFoldedPath(relpath string) (*Blob, error) {
+ entries, err := t.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, entry := range entries {
+ if strings.EqualFold(entry.Name(), relpath) {
+ return t.GetBlobByPath(entry.Name())
+ }
+ }
+
+ return nil, ErrNotExist{"", relpath}
+}
diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go
new file mode 100644
index 0000000..0d9cfd2
--- /dev/null
+++ b/modules/git/tree_entry.go
@@ -0,0 +1,277 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "io"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// TreeEntry the leaf in the git tree
+type TreeEntry struct {
+ ID ObjectID
+
+ ptree *Tree
+
+ entryMode EntryMode
+ name string
+
+ size int64
+ sized bool
+ fullName string
+}
+
+// Name returns the name of the entry
+func (te *TreeEntry) Name() string {
+ if te.fullName != "" {
+ return te.fullName
+ }
+ return te.name
+}
+
+// Mode returns the mode of the entry
+func (te *TreeEntry) Mode() EntryMode {
+ return te.entryMode
+}
+
+// Size returns the size of the entry
+func (te *TreeEntry) Size() int64 {
+ if te.IsDir() {
+ return 0
+ } else if te.sized {
+ return te.size
+ }
+
+ wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(te.ptree.repo.Ctx)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(te.ID.String() + "\n"))
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+ _, _, te.size, err = ReadBatchLine(rd)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+
+ te.sized = true
+ return te.size
+}
+
+// IsSubModule if the entry is a sub module
+func (te *TreeEntry) IsSubModule() bool {
+ return te.entryMode == EntryModeCommit
+}
+
+// IsDir if the entry is a sub dir
+func (te *TreeEntry) IsDir() bool {
+ return te.entryMode == EntryModeTree
+}
+
+// IsLink if the entry is a symlink
+func (te *TreeEntry) IsLink() bool {
+ return te.entryMode == EntryModeSymlink
+}
+
+// IsRegular if the entry is a regular file
+func (te *TreeEntry) IsRegular() bool {
+ return te.entryMode == EntryModeBlob
+}
+
+// IsExecutable if the entry is an executable file (not necessarily binary)
+func (te *TreeEntry) IsExecutable() bool {
+ return te.entryMode == EntryModeExec
+}
+
+// Blob returns the blob object the entry
+func (te *TreeEntry) Blob() *Blob {
+ return &Blob{
+ ID: te.ID,
+ name: te.Name(),
+ size: te.size,
+ gotSize: te.sized,
+ repo: te.ptree.repo,
+ }
+}
+
+// Type returns the type of the entry (commit, tree, blob)
+func (te *TreeEntry) Type() string {
+ switch te.Mode() {
+ case EntryModeCommit:
+ return "commit"
+ case EntryModeTree:
+ return "tree"
+ default:
+ return "blob"
+ }
+}
+
+// FollowLink returns the entry pointed to by a symlink
+func (te *TreeEntry) FollowLink() (*TreeEntry, string, error) {
+ if !te.IsLink() {
+ return nil, "", ErrBadLink{te.Name(), "not a symlink"}
+ }
+
+ // read the link
+ r, err := te.Blob().DataAsync()
+ if err != nil {
+ return nil, "", err
+ }
+ closed := false
+ defer func() {
+ if !closed {
+ _ = r.Close()
+ }
+ }()
+ buf := make([]byte, te.Size())
+ _, err = io.ReadFull(r, buf)
+ if err != nil {
+ return nil, "", err
+ }
+ _ = r.Close()
+ closed = true
+
+ lnk := string(buf)
+ t := te.ptree
+
+ // traverse up directories
+ for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] {
+ t = t.ptree
+ }
+
+ if t == nil {
+ return nil, "", ErrBadLink{te.Name(), "points outside of repo"}
+ }
+
+ target, err := t.GetTreeEntryByPath(lnk)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return nil, "", ErrBadLink{te.Name(), "broken link"}
+ }
+ return nil, "", err
+ }
+ return target, lnk, nil
+}
+
+// FollowLinks returns the entry ultimately pointed to by a symlink
+func (te *TreeEntry) FollowLinks() (*TreeEntry, string, error) {
+ if !te.IsLink() {
+ return nil, "", ErrBadLink{te.Name(), "not a symlink"}
+ }
+ entry := te
+ entryLink := ""
+ for i := 0; i < 999; i++ {
+ if entry.IsLink() {
+ next, link, err := entry.FollowLink()
+ entryLink = link
+ if err != nil {
+ return nil, "", err
+ }
+ if next.ID == entry.ID {
+ return nil, "", ErrBadLink{
+ entry.Name(),
+ "recursive link",
+ }
+ }
+ entry = next
+ } else {
+ break
+ }
+ }
+ if entry.IsLink() {
+ return nil, "", ErrBadLink{
+ te.Name(),
+ "too many levels of symbolic links",
+ }
+ }
+ return entry, entryLink, nil
+}
+
+// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree
+func (te *TreeEntry) Tree() *Tree {
+ t, err := te.ptree.repo.getTree(te.ID)
+ if err != nil {
+ return nil
+ }
+ t.ptree = te.ptree
+ return t
+}
+
+// GetSubJumpablePathName return the full path of subdirectory jumpable ( contains only one directory )
+func (te *TreeEntry) GetSubJumpablePathName() string {
+ if te.IsSubModule() || !te.IsDir() {
+ return ""
+ }
+ tree, err := te.ptree.SubTree(te.Name())
+ if err != nil {
+ return te.Name()
+ }
+ entries, _ := tree.ListEntries()
+ if len(entries) == 1 && entries[0].IsDir() {
+ name := entries[0].GetSubJumpablePathName()
+ if name != "" {
+ return te.Name() + "/" + name
+ }
+ }
+ return te.Name()
+}
+
+// Entries a list of entry
+type Entries []*TreeEntry
+
+type customSortableEntries struct {
+ Comparer func(s1, s2 string) bool
+ Entries
+}
+
+var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{
+ func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
+ return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule()
+ },
+ func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
+ return cmp(t1.Name(), t2.Name())
+ },
+}
+
+func (ctes customSortableEntries) Len() int { return len(ctes.Entries) }
+
+func (ctes customSortableEntries) Swap(i, j int) {
+ ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i]
+}
+
+func (ctes customSortableEntries) Less(i, j int) bool {
+ t1, t2 := ctes.Entries[i], ctes.Entries[j]
+ var k int
+ for k = 0; k < len(sorter)-1; k++ {
+ s := sorter[k]
+ switch {
+ case s(t1, t2, ctes.Comparer):
+ return true
+ case s(t2, t1, ctes.Comparer):
+ return false
+ }
+ }
+ return sorter[k](t1, t2, ctes.Comparer)
+}
+
+// Sort sort the list of entry
+func (tes Entries) Sort() {
+ sort.Sort(customSortableEntries{func(s1, s2 string) bool {
+ return s1 < s2
+ }, tes})
+}
+
+// CustomSort customizable string comparing sort entry list
+func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) {
+ sort.Sort(customSortableEntries{cmp, tes})
+}
diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go
new file mode 100644
index 0000000..a399118
--- /dev/null
+++ b/modules/git/tree_entry_mode.go
@@ -0,0 +1,35 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import "strconv"
+
+// EntryMode the type of the object in the git tree
+type EntryMode int
+
+// There are only a few file modes in Git. They look like unix file modes, but they can only be
+// one of these.
+const (
+ // EntryModeBlob
+ EntryModeBlob EntryMode = 0o100644
+ // EntryModeExec
+ EntryModeExec EntryMode = 0o100755
+ // EntryModeSymlink
+ EntryModeSymlink EntryMode = 0o120000
+ // EntryModeCommit
+ EntryModeCommit EntryMode = 0o160000
+ // EntryModeTree
+ EntryModeTree EntryMode = 0o040000
+)
+
+// String converts an EntryMode to a string
+func (e EntryMode) String() string {
+ return strconv.FormatInt(int64(e), 8)
+}
+
+// ToEntryMode converts a string to an EntryMode
+func ToEntryMode(value string) EntryMode {
+ v, _ := strconv.ParseInt(value, 8, 32)
+ return EntryMode(v)
+}
diff --git a/modules/git/tree_test.go b/modules/git/tree_test.go
new file mode 100644
index 0000000..6e5d7f4
--- /dev/null
+++ b/modules/git/tree_test.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSubTree_Issue29101(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("ce064814f4a0d337b333e646ece456cd39fab612")
+ require.NoError(t, err)
+
+ // old code could produce a different error if called multiple times
+ for i := 0; i < 10; i++ {
+ _, err = commit.SubTree("file1.txt")
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ }
+}
diff --git a/modules/git/url/url.go b/modules/git/url/url.go
new file mode 100644
index 0000000..6376851
--- /dev/null
+++ b/modules/git/url/url.go
@@ -0,0 +1,89 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package url
+
+import (
+ "fmt"
+ stdurl "net/url"
+ "strings"
+)
+
+// ErrWrongURLFormat represents an error with wrong url format
+type ErrWrongURLFormat struct {
+ URL string
+}
+
+func (err ErrWrongURLFormat) Error() string {
+ return fmt.Sprintf("git URL %s format is wrong", err.URL)
+}
+
+// GitURL represents a git URL
+type GitURL struct {
+ *stdurl.URL
+ extraMark int // 0 no extra 1 scp 2 file path with no prefix
+}
+
+// String returns the URL's string
+func (u *GitURL) String() string {
+ switch u.extraMark {
+ case 0:
+ return u.URL.String()
+ case 1:
+ return fmt.Sprintf("%s@%s:%s", u.User.Username(), u.Host, u.Path)
+ case 2:
+ return u.Path
+ default:
+ return ""
+ }
+}
+
+// Parse parse all kinds of git URL
+func Parse(remote string) (*GitURL, error) {
+ if strings.Contains(remote, "://") {
+ u, err := stdurl.Parse(remote)
+ if err != nil {
+ return nil, err
+ }
+ return &GitURL{URL: u}, nil
+ } else if strings.Contains(remote, "@") && strings.Contains(remote, ":") {
+ url := stdurl.URL{
+ Scheme: "ssh",
+ }
+ squareBrackets := false
+ lastIndex := -1
+ FOR:
+ for i := 0; i < len(remote); i++ {
+ switch remote[i] {
+ case '@':
+ url.User = stdurl.User(remote[:i])
+ lastIndex = i + 1
+ case ':':
+ if !squareBrackets {
+ url.Host = strings.ReplaceAll(remote[lastIndex:i], "%25", "%")
+ if len(remote) <= i+1 {
+ return nil, ErrWrongURLFormat{URL: remote}
+ }
+ url.Path = remote[i+1:]
+ break FOR
+ }
+ case '[':
+ squareBrackets = true
+ case ']':
+ squareBrackets = false
+ }
+ }
+ return &GitURL{
+ URL: &url,
+ extraMark: 1,
+ }, nil
+ }
+
+ return &GitURL{
+ URL: &stdurl.URL{
+ Scheme: "file",
+ Path: remote,
+ },
+ extraMark: 2,
+ }, nil
+}
diff --git a/modules/git/url/url_test.go b/modules/git/url/url_test.go
new file mode 100644
index 0000000..e1e52c0
--- /dev/null
+++ b/modules/git/url/url_test.go
@@ -0,0 +1,167 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package url
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseGitURLs(t *testing.T) {
+ kases := []struct {
+ kase string
+ expected *GitURL
+ }{
+ {
+ kase: "git@127.0.0.1:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "127.0.0.1",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[fe80:14fc:cec5:c174:d88%10]",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@[::1]:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[::1]",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@github.com:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "github.com",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "ssh://git@github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "ssh://git@[::1]/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[::1]",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "/repositories/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "file",
+ Path: "/repositories/go-gitea/gitea.git",
+ },
+ extraMark: 2,
+ },
+ },
+ {
+ kase: "file:///repositories/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "file",
+ Path: "/repositories/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://git:git@github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "github.com",
+ User: url.UserPassword("git", "git"),
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "[fe80:14fc:cec5:c174:d88%10]:20",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+
+ {
+ kase: "git://github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "git",
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.kase, func(t *testing.T) {
+ u, err := Parse(kase.kase)
+ require.NoError(t, err)
+ assert.EqualValues(t, kase.expected.extraMark, u.extraMark)
+ assert.EqualValues(t, *kase.expected, *u)
+ })
+ }
+}
diff --git a/modules/git/utils.go b/modules/git/utils.go
new file mode 100644
index 0000000..53211c6
--- /dev/null
+++ b/modules/git/utils.go
@@ -0,0 +1,138 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// ObjectCache provides thread-safe cache operations.
+type ObjectCache struct {
+ lock sync.RWMutex
+ cache map[string]any
+}
+
+func newObjectCache() *ObjectCache {
+ return &ObjectCache{
+ cache: make(map[string]any, 10),
+ }
+}
+
+// Set add obj to cache
+func (oc *ObjectCache) Set(id string, obj any) {
+ oc.lock.Lock()
+ defer oc.lock.Unlock()
+
+ oc.cache[id] = obj
+}
+
+// Get get cached obj by id
+func (oc *ObjectCache) Get(id string) (any, bool) {
+ oc.lock.RLock()
+ defer oc.lock.RUnlock()
+
+ obj, has := oc.cache[id]
+ return obj, has
+}
+
+// isDir returns true if given path is a directory,
+// or returns false when it's a file or does not exist.
+func isDir(dir string) bool {
+ f, e := os.Stat(dir)
+ if e != nil {
+ return false
+ }
+ return f.IsDir()
+}
+
+// isFile returns true if given path is a file,
+// or returns false when it's a directory or does not exist.
+func isFile(filePath string) bool {
+ f, e := os.Stat(filePath)
+ if e != nil {
+ return false
+ }
+ return !f.IsDir()
+}
+
+// isExist checks whether a file or directory exists.
+// It returns false when the file or directory does not exist.
+func isExist(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil || os.IsExist(err)
+}
+
+// ConcatenateError concatenats an error with stderr string
+func ConcatenateError(err error, stderr string) error {
+ if len(stderr) == 0 {
+ return err
+ }
+ return fmt.Errorf("%w - %s", err, stderr)
+}
+
+// ParseBool returns the boolean value represented by the string as per git's git_config_bool
+// true will be returned for the result if the string is empty, but valid will be false.
+// "true", "yes", "on" are all true, true
+// "false", "no", "off" are all false, true
+// 0 is false, true
+// Any other integer is true, true
+// Anything else will return false, false
+func ParseBool(value string) (result, valid bool) {
+ // Empty strings are true but invalid
+ if len(value) == 0 {
+ return true, false
+ }
+ // These are the git expected true and false values
+ if strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") || strings.EqualFold(value, "on") {
+ return true, true
+ }
+ if strings.EqualFold(value, "false") || strings.EqualFold(value, "no") || strings.EqualFold(value, "off") {
+ return false, true
+ }
+ // Try a number
+ intValue, err := strconv.ParseInt(value, 10, 32)
+ if err != nil {
+ return false, false
+ }
+ return intValue != 0, true
+}
+
+// LimitedReaderCloser is a limited reader closer
+type LimitedReaderCloser struct {
+ R io.Reader
+ C io.Closer
+ N int64
+}
+
+// Read implements io.Reader
+func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) {
+ if l.N <= 0 {
+ _ = l.C.Close()
+ return 0, io.EOF
+ }
+ if int64(len(p)) > l.N {
+ p = p[0:l.N]
+ }
+ n, err = l.R.Read(p)
+ l.N -= int64(n)
+ return n, err
+}
+
+// Close implements io.Closer
+func (l *LimitedReaderCloser) Close() error {
+ return l.C.Close()
+}
+
+func HashFilePathForWebUI(s string) string {
+ h := sha1.New()
+ _, _ = h.Write([]byte(s))
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/modules/git/utils_test.go b/modules/git/utils_test.go
new file mode 100644
index 0000000..a8c3fe3
--- /dev/null
+++ b/modules/git/utils_test.go
@@ -0,0 +1,26 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// This file contains utility functions that are used across multiple tests,
+// but not in production code.
+
+func skipIfSHA256NotSupported(t *testing.T) {
+ if CheckGitVersionAtLeast("2.42") != nil {
+ t.Skip("skipping because installed Git version doesn't support SHA256")
+ }
+}
+
+func TestHashFilePathForWebUI(t *testing.T) {
+ assert.Equal(t,
+ "8843d7f92416211de9ebb963ff4ce28125932878",
+ HashFilePathForWebUI("foobar"),
+ )
+}
diff --git a/modules/gitgraph/graph.go b/modules/gitgraph/graph.go
new file mode 100644
index 0000000..331ad6b
--- /dev/null
+++ b/modules/gitgraph/graph.go
@@ -0,0 +1,116 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "os"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// GetCommitGraph return a list of commit (GraphItems) from all branches
+func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bool, branches, files []string) (*Graph, error) {
+ format := "DATA:%D|%H|%ad|%h|%s"
+
+ if page == 0 {
+ page = 1
+ }
+
+ graphCmd := git.NewCommand(r.Ctx, "log", "--graph", "--date-order", "--decorate=full")
+
+ if hidePRRefs {
+ graphCmd.AddArguments("--exclude=" + git.PullPrefix + "*")
+ }
+
+ if len(branches) == 0 {
+ graphCmd.AddArguments("--all")
+ }
+
+ graphCmd.AddArguments("-C", "-M", "--date=iso").
+ AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
+ AddOptionFormat("--pretty=format:%s", format)
+
+ if len(branches) > 0 {
+ graphCmd.AddDynamicArguments(branches...)
+ }
+ if len(files) > 0 {
+ graphCmd.AddDashesAndList(files...)
+ }
+ graph := NewGraph()
+
+ stderr := new(strings.Builder)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ commitsToSkip := setting.UI.GraphMaxCommitNum * (page - 1)
+
+ scanner := bufio.NewScanner(stdoutReader)
+
+ if err := graphCmd.Run(&git.RunOpts{
+ Dir: r.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ defer stdoutReader.Close()
+ parser := &Parser{}
+ parser.firstInUse = -1
+ parser.maxAllowedColors = maxAllowedColors
+ if maxAllowedColors > 0 {
+ parser.availableColors = make([]int, maxAllowedColors)
+ for i := range parser.availableColors {
+ parser.availableColors[i] = i + 1
+ }
+ } else {
+ parser.availableColors = []int{1, 2}
+ }
+ for commitsToSkip > 0 && scanner.Scan() {
+ line := scanner.Bytes()
+ dataIdx := bytes.Index(line, []byte("DATA:"))
+ if dataIdx < 0 {
+ dataIdx = len(line)
+ }
+ starIdx := bytes.IndexByte(line, '*')
+ if starIdx >= 0 && starIdx < dataIdx {
+ commitsToSkip--
+ }
+ parser.ParseGlyphs(line[:dataIdx])
+ }
+
+ row := 0
+
+ // Skip initial non-commit lines
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if bytes.IndexByte(line, '*') >= 0 {
+ if err := parser.AddLineToGraph(graph, row, line); err != nil {
+ cancel()
+ return err
+ }
+ break
+ }
+ parser.ParseGlyphs(line)
+ }
+
+ for scanner.Scan() {
+ row++
+ line := scanner.Bytes()
+ if err := parser.AddLineToGraph(graph, row, line); err != nil {
+ cancel()
+ return err
+ }
+ }
+ return scanner.Err()
+ },
+ }); err != nil {
+ return graph, err
+ }
+ return graph, nil
+}
diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go
new file mode 100644
index 0000000..82f460e
--- /dev/null
+++ b/modules/gitgraph/graph_models.go
@@ -0,0 +1,256 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// NewGraph creates a basic graph
+func NewGraph() *Graph {
+ graph := &Graph{}
+ graph.relationCommit = &Commit{
+ Row: -1,
+ Column: -1,
+ }
+ graph.Flows = map[int64]*Flow{}
+ return graph
+}
+
+// Graph represents a collection of flows
+type Graph struct {
+ Flows map[int64]*Flow
+ Commits []*Commit
+ MinRow int
+ MinColumn int
+ MaxRow int
+ MaxColumn int
+ relationCommit *Commit
+}
+
+// Width returns the width of the graph
+func (graph *Graph) Width() int {
+ return graph.MaxColumn - graph.MinColumn + 1
+}
+
+// Height returns the height of the graph
+func (graph *Graph) Height() int {
+ return graph.MaxRow - graph.MinRow + 1
+}
+
+// AddGlyph adds glyph to flows
+func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) {
+ flow, ok := graph.Flows[flowID]
+ if !ok {
+ flow = NewFlow(flowID, color, row, column)
+ graph.Flows[flowID] = flow
+ }
+ flow.AddGlyph(row, column, glyph)
+
+ if row < graph.MinRow {
+ graph.MinRow = row
+ }
+ if row > graph.MaxRow {
+ graph.MaxRow = row
+ }
+ if column < graph.MinColumn {
+ graph.MinColumn = column
+ }
+ if column > graph.MaxColumn {
+ graph.MaxColumn = column
+ }
+}
+
+// AddCommit adds a commit at row, column on flowID with the provided data
+func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error {
+ commit, err := NewCommit(row, column, data)
+ if err != nil {
+ return err
+ }
+ commit.Flow = flowID
+ graph.Commits = append(graph.Commits, commit)
+
+ graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit)
+ return nil
+}
+
+// LoadAndProcessCommits will load the git.Commits for each commit in the graph,
+// the associate the commit with the user author, and check the commit verification
+// before finally retrieving the latest status
+func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error {
+ var err error
+ var ok bool
+
+ emails := map[string]*user_model.User{}
+ keyMap := map[string]bool{}
+
+ for _, c := range graph.Commits {
+ if len(c.Rev) == 0 {
+ continue
+ }
+ c.Commit, err = gitRepo.GetCommit(c.Rev)
+ if err != nil {
+ return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err)
+ }
+
+ if c.Commit.Author != nil {
+ email := c.Commit.Author.Email
+ if c.User, ok = emails[email]; !ok {
+ c.User, _ = user_model.GetUserByEmail(ctx, email)
+ emails[email] = c.User
+ }
+ }
+
+ c.Verification = asymkey_model.ParseCommitWithSignature(ctx, c.Commit)
+
+ _ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
+ return repo_model.IsOwnerMemberCollaborator(ctx, repository, user.ID)
+ }, &keyMap)
+
+ statuses, _, err := git_model.GetLatestCommitStatus(ctx, repository.ID, c.Commit.ID.String(), db.ListOptions{})
+ if err != nil {
+ log.Error("GetLatestCommitStatus: %v", err)
+ } else {
+ c.Status = git_model.CalcCommitStatus(statuses)
+ }
+ }
+ return nil
+}
+
+// NewFlow creates a new flow
+func NewFlow(flowID int64, color, row, column int) *Flow {
+ return &Flow{
+ ID: flowID,
+ ColorNumber: color,
+ MinRow: row,
+ MinColumn: column,
+ MaxRow: row,
+ MaxColumn: column,
+ }
+}
+
+// Flow represents a series of glyphs
+type Flow struct {
+ ID int64
+ ColorNumber int
+ Glyphs []Glyph
+ Commits []*Commit
+ MinRow int
+ MinColumn int
+ MaxRow int
+ MaxColumn int
+}
+
+// Color16 wraps the color numbers around mod 16
+func (flow *Flow) Color16() int {
+ return flow.ColorNumber % 16
+}
+
+// AddGlyph adds glyph at row and column
+func (flow *Flow) AddGlyph(row, column int, glyph byte) {
+ if row < flow.MinRow {
+ flow.MinRow = row
+ }
+ if row > flow.MaxRow {
+ flow.MaxRow = row
+ }
+ if column < flow.MinColumn {
+ flow.MinColumn = column
+ }
+ if column > flow.MaxColumn {
+ flow.MaxColumn = column
+ }
+
+ flow.Glyphs = append(flow.Glyphs, Glyph{
+ row,
+ column,
+ glyph,
+ })
+}
+
+// Glyph represents a coordinate and glyph
+type Glyph struct {
+ Row int
+ Column int
+ Glyph byte
+}
+
+// RelationCommit represents an empty relation commit
+var RelationCommit = &Commit{
+ Row: -1,
+}
+
+// NewCommit creates a new commit from a provided line
+func NewCommit(row, column int, line []byte) (*Commit, error) {
+ data := bytes.SplitN(line, []byte("|"), 5)
+ if len(data) < 5 {
+ return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
+ }
+ return &Commit{
+ Row: row,
+ Column: column,
+ // 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1)
+ Refs: newRefsFromRefNames(data[0]),
+ // 1 matches git log --pretty=format:%H => commit hash
+ Rev: string(data[1]),
+ // 2 matches git log --pretty=format:%ad => author date (format respects --date= option)
+ Date: string(data[2]),
+ // 3 matches git log --pretty=format:%h => abbreviated commit hash
+ ShortRev: string(data[3]),
+ // 4 matches git log --pretty=format:%s => subject
+ Subject: string(data[4]),
+ }, nil
+}
+
+func newRefsFromRefNames(refNames []byte) []git.Reference {
+ refBytes := bytes.Split(refNames, []byte{',', ' '})
+ refs := make([]git.Reference, 0, len(refBytes))
+ for _, refNameBytes := range refBytes {
+ if len(refNameBytes) == 0 {
+ continue
+ }
+ refName := string(refNameBytes)
+ if strings.HasPrefix(refName, "tag: ") {
+ refName = strings.TrimPrefix(refName, "tag: ")
+ } else {
+ refName = strings.TrimPrefix(refName, "HEAD -> ")
+ }
+ refs = append(refs, git.Reference{
+ Name: refName,
+ })
+ }
+ return refs
+}
+
+// Commit represents a commit at coordinate X, Y with the data
+type Commit struct {
+ Commit *git.Commit
+ User *user_model.User
+ Verification *asymkey_model.ObjectVerification
+ Status *git_model.CommitStatus
+ Flow int64
+ Row int
+ Column int
+ Refs []git.Reference
+ Rev string
+ Date string
+ ShortRev string
+ Subject string
+}
+
+// OnlyRelation returns whether this a relation only commit
+func (c *Commit) OnlyRelation() bool {
+ return c.Row == -1
+}
diff --git a/modules/gitgraph/graph_test.go b/modules/gitgraph/graph_test.go
new file mode 100644
index 0000000..18d427a
--- /dev/null
+++ b/modules/gitgraph/graph_test.go
@@ -0,0 +1,714 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+func BenchmarkGetCommitGraph(b *testing.B) {
+ currentRepo, err := git.OpenRepository(git.DefaultContext, ".")
+ if err != nil || currentRepo == nil {
+ b.Error("Could not open repository")
+ }
+ defer currentRepo.Close()
+
+ for i := 0; i < b.N; i++ {
+ graph, err := GetCommitGraph(currentRepo, 1, 0, false, nil, nil)
+ if err != nil {
+ b.Error("Could get commit graph")
+ }
+
+ if len(graph.Commits) < 100 {
+ b.Error("Should get 100 log lines.")
+ }
+ }
+}
+
+func BenchmarkParseCommitString(b *testing.B) {
+ testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
+
+ parser := &Parser{}
+ parser.Reset()
+ for i := 0; i < b.N; i++ {
+ parser.Reset()
+ graph := NewGraph()
+ if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil {
+ b.Error("could not parse teststring")
+ }
+ if graph.Flows[1].Commits[0].Rev != "4e61bacab44e9b4730e44a6615d04098dd3a8eaf" {
+ b.Error("Did not get expected data")
+ }
+ }
+}
+
+func BenchmarkParseGlyphs(b *testing.B) {
+ parser := &Parser{}
+ parser.Reset()
+ tgBytes := []byte(testglyphs)
+ var tg []byte
+ for i := 0; i < b.N; i++ {
+ parser.Reset()
+ tg = tgBytes
+ idx := bytes.Index(tg, []byte("\n"))
+ for idx > 0 {
+ parser.ParseGlyphs(tg[:idx])
+ tg = tg[idx+1:]
+ idx = bytes.Index(tg, []byte("\n"))
+ }
+ }
+}
+
+func TestReleaseUnusedColors(t *testing.T) {
+ testcases := []struct {
+ availableColors []int
+ oldColors []int
+ firstInUse int // these values have to be either be correct or suggest less is
+ firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it
+ }{
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{1, 1, 1, 1, 1},
+ firstAvailable: -1,
+ firstInUse: 1,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{1, 2, 3, 4},
+ firstAvailable: 6,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0},
+ firstAvailable: 6,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+ oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7},
+ firstAvailable: -1,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+ oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7},
+ firstAvailable: -1,
+ firstInUse: 0,
+ },
+ }
+ for _, testcase := range testcases {
+ parser := &Parser{}
+ parser.Reset()
+ parser.availableColors = append([]int{}, testcase.availableColors...)
+ parser.oldColors = append(parser.oldColors, testcase.oldColors...)
+ parser.firstAvailable = testcase.firstAvailable
+ parser.firstInUse = testcase.firstInUse
+ parser.releaseUnusedColors()
+
+ if parser.firstAvailable == -1 {
+ // All in use
+ for _, color := range parser.availableColors {
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ } else if parser.firstInUse != -1 {
+ // Some in use
+ for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) {
+ color := parser.availableColors[i]
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) {
+ color := parser.availableColors[i]
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ } else {
+ // None in use
+ for _, color := range parser.oldColors {
+ if color != 0 {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ }
+ }
+}
+
+func TestParseGlyphs(t *testing.T) {
+ parser := &Parser{}
+ parser.Reset()
+ tgBytes := []byte(testglyphs)
+ tg := tgBytes
+ idx := bytes.Index(tg, []byte("\n"))
+ row := 0
+ for idx > 0 {
+ parser.ParseGlyphs(tg[:idx])
+ tg = tg[idx+1:]
+ idx = bytes.Index(tg, []byte("\n"))
+ if parser.flows[0] != 1 {
+ t.Errorf("First column flow should be 1 but was %d", parser.flows[0])
+ }
+ colorToFlow := map[int]int64{}
+ flowToColor := map[int64]int{}
+
+ for i, flow := range parser.flows {
+ if flow == 0 {
+ continue
+ }
+ color := parser.colors[i]
+
+ if fColor, in := flowToColor[flow]; in && fColor != color {
+ t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor)
+ }
+ flowToColor[flow] = color
+ if cFlow, in := colorToFlow[color]; in && cFlow != flow {
+ t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow)
+ }
+ colorToFlow[color] = flow
+ }
+ row++
+ }
+ if len(parser.availableColors) != 9 {
+ t.Errorf("Expected 9 colors but have %d", len(parser.availableColors))
+ }
+}
+
+func TestCommitStringParsing(t *testing.T) {
+ dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
+ tests := []struct {
+ shouldPass bool
+ testName string
+ commitMessage string
+ }{
+ {true, "normal", "not a fancy message"},
+ {true, "extra pipe", "An extra pipe: |"},
+ {true, "extra 'Data:'", "DATA: might be trouble"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.testName, func(t *testing.T) {
+ testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
+ idx := strings.Index(testString, "DATA:")
+ commit, err := NewCommit(0, 0, []byte(testString[idx+5:]))
+ if err != nil && test.shouldPass {
+ t.Errorf("Could not parse %s", testString)
+ return
+ }
+
+ if test.commitMessage != commit.Subject {
+ t.Errorf("%s does not match %s", test.commitMessage, commit.Subject)
+ }
+ })
+ }
+}
+
+var testglyphs = `*
+*
+*
+*
+*
+*
+*
+*
+|\
+* |
+* |
+* |
+* |
+* |
+| *
+* |
+| *
+| |\
+* | |
+| | *
+| | |\
+* | | \
+|\ \ \ \
+| * | | |
+| |\| | |
+* | | | |
+|/ / / /
+| | | *
+| * | |
+| * | |
+| * | |
+* | | |
+* | | |
+* | | |
+* | | |
+* | | |
+|\ \ \ \
+| | * | |
+| | |\| |
+| | | * |
+| | | | *
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | |/ / /
+| |/| | |
+| | | | *
+| * | | |
+|/| | | |
+| * | | |
+|/| | | |
+| | |/ /
+| |/| |
+| * | |
+| * | |
+| |\ \ \
+| | * | |
+| |/| | |
+| | | |/
+| | |/|
+| * | |
+| * | |
+| * | |
+| | * |
+| | |\ \
+| | | * |
+| | |/| |
+| | | * |
+| | | |\ \
+| | | | * |
+| | | |/| |
+| | * | | |
+| | * | | |
+| | |\ \ \ \
+| | | * | | |
+| | |/| | | |
+| | | | | * |
+| | | | |/ /
+* | | | / /
+|/ / / / /
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| * | | | |
+| * | | | |
+| |\ \ \ \ \
+| | | * \ \ \
+| | | |\ \ \ \
+| | | | * | | |
+| | | |/| | | |
+| | | | | |/ /
+| | | | |/| |
+* | | | | | |
+* | | | | | |
+* | | | | | |
+| | | | * | |
+* | | | | | |
+| | * | | | |
+| |/| | | | |
+* | | | | | |
+| |/ / / / /
+|/| | | | |
+| | | | * |
+| | | |/ /
+| | |/| |
+| * | | |
+| | | | *
+| | * | |
+| | |\ \ \
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | | * |
+| | * | |
+| | |\ \ \
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | | * |
+* | | | |
+|\ \ \ \ \
+| * \ \ \ \
+| |\ \ \ \ \
+| | | |/ / /
+| | |/| | |
+| | | | * |
+| | | | * |
+* | | | | |
+* | | | | |
+|/ / / / /
+| | | * |
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | * | | |
+| | |\ \ \ \
+| | | * | | |
+| | |/| | | |
+| |/| | |/ /
+| | | |/| |
+| | | | | *
+| |_|_|_|/
+|/| | | |
+| | * | |
+| |/ / /
+* | | |
+* | | |
+| | * |
+* | | |
+* | | |
+| * | |
+| | * |
+| * | |
+* | | |
+|\ \ \ \
+| * | | |
+|/| | | |
+| |/ / /
+| * | |
+| |\ \ \
+| | * | |
+| |/| | |
+| | |/ /
+| | * |
+| | |\ \
+| | | * |
+| | |/| |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | * | | |
+| | * | | |
+| | * | | |
+| |/ / / /
+| * | | |
+| |\ \ \ \
+| | * | | |
+| |/| | | |
+* | | | | |
+* | | | | |
+* | | | | |
+* | | | | |
+* | | | | |
+| | | | * |
+* | | | | |
+|\ \ \ \ \ \
+| * | | | | |
+|/| | | | | |
+| | | | | * |
+| | | | |/ /
+* | | | | |
+|\ \ \ \ \ \
+* | | | | | |
+* | | | | | |
+| | | | * | |
+* | | | | | |
+* | | | | | |
+|\ \ \ \ \ \ \
+| | |_|_|/ / /
+| |/| | | | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | |/ / /
+| | | * | |
+| | | * | |
+| | | * | |
+| | |/| | |
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | * | |
+| |/| | |
+| | | * |
+| | |/ /
+| | * |
+| * | |
+| |\ \ \
+| * | | |
+| | * | |
+| |/| | |
+| | |/ /
+| | * |
+| | |\ \
+| | * | |
+* | | | |
+|\| | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| * | | |
+| |\| | |
+| * | | |
+| | * | |
+| | * | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+* | | | |
+|\| | | |
+| | * | |
+| * | | |
+| |\| | |
+| | * | |
+| | * | |
+| | * | |
+| | | * |
+* | | | |
+|\| | | |
+| | * | |
+| | |/ /
+| * | |
+| * | |
+| |\| |
+* | | |
+|\| | |
+| | * |
+| | * |
+| | * |
+| * | |
+| | * |
+| * | |
+| | * |
+| | * |
+| | * |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| |\| |
+| | * |
+| | |\ \
+* | | | |
+|\| | | |
+| * | | |
+| |\| | |
+| | * | |
+| | | * |
+| | |/ /
+* | | |
+* | | |
+|\| | |
+| * | |
+| |\| |
+| | * |
+| | * |
+| | * |
+| | | *
+* | | |
+|\| | |
+| * | |
+| * | |
+| | | *
+| | | |\
+* | | | |
+| |_|_|/
+|/| | |
+| * | |
+| |\| |
+| | * |
+| | * |
+| | * |
+| | * |
+| | * |
+| * | |
+* | | |
+|\| | |
+| * | |
+|/| | |
+| |/ /
+| * |
+| |\ \
+| * | |
+| * | |
+* | | |
+|\| | |
+| | * |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| * | |
+| | * |
+| | |\ \
+| | |/ /
+| |/| |
+| * | |
+* | | |
+|\| | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| |\ \ \
+| * | | |
+| * | | |
+| | | * |
+| * | | |
+| * | | |
+| | |/ /
+| |/| |
+| | * |
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| |\ \ \
+* | | | |
+|\| | | |
+| * | | |
+| * | | |
+* | | | |
+* | | | |
+|\| | | |
+| | | | *
+| | | | |\
+| |_|_|_|/
+|/| | | |
+| * | | |
+* | | | |
+* | | | |
+|\| | | |
+| * | | |
+| |\ \ \ \
+| | | |/ /
+| | |/| |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| | | * |
+| | |/ /
+| |/| |
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| * | |
+* | | |
+| * | |
+| * | |
+| * | |
+* | | |
+* | | |
+* | | |
+|\| | |
+| * | |
+* | | |
+* | | |
+* | | |
+* | | |
+| | | *
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+`
diff --git a/modules/gitgraph/parser.go b/modules/gitgraph/parser.go
new file mode 100644
index 0000000..f6bf9b0
--- /dev/null
+++ b/modules/gitgraph/parser.go
@@ -0,0 +1,336 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "fmt"
+)
+
+// Parser represents a git graph parser. It is stateful containing the previous
+// glyphs, detected flows and color assignments.
+type Parser struct {
+ glyphs []byte
+ oldGlyphs []byte
+ flows []int64
+ oldFlows []int64
+ maxFlow int64
+ colors []int
+ oldColors []int
+ availableColors []int
+ nextAvailable int
+ firstInUse int
+ firstAvailable int
+ maxAllowedColors int
+}
+
+// Reset resets the internal parser state.
+func (parser *Parser) Reset() {
+ parser.glyphs = parser.glyphs[0:0]
+ parser.oldGlyphs = parser.oldGlyphs[0:0]
+ parser.flows = parser.flows[0:0]
+ parser.oldFlows = parser.oldFlows[0:0]
+ parser.maxFlow = 0
+ parser.colors = parser.colors[0:0]
+ parser.oldColors = parser.oldColors[0:0]
+ parser.availableColors = parser.availableColors[0:0]
+ parser.availableColors = append(parser.availableColors, 1, 2)
+ parser.nextAvailable = 0
+ parser.firstInUse = -1
+ parser.firstAvailable = 0
+ parser.maxAllowedColors = 0
+}
+
+// AddLineToGraph adds the line as a row to the graph
+func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
+ idx := bytes.Index(line, []byte("DATA:"))
+ if idx < 0 {
+ parser.ParseGlyphs(line)
+ } else {
+ parser.ParseGlyphs(line[:idx])
+ }
+
+ var err error
+ commitDone := false
+
+ for column, glyph := range parser.glyphs {
+ if glyph == ' ' {
+ continue
+ }
+
+ flowID := parser.flows[column]
+
+ graph.AddGlyph(row, column, flowID, parser.colors[column], glyph)
+
+ if glyph == '*' {
+ if commitDone {
+ if err != nil {
+ err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err)
+ } else {
+ err = fmt.Errorf("double commit on line %d: %s", row, string(line))
+ }
+ }
+ commitDone = true
+ if idx < 0 {
+ if err != nil {
+ err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err)
+ } else {
+ err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line))
+ }
+ continue
+ }
+ err2 := graph.AddCommit(row, column, flowID, line[idx+5:])
+ if err != nil && err2 != nil {
+ err = fmt.Errorf("%v %w", err2, err)
+ continue
+ } else if err2 != nil {
+ err = err2
+ continue
+ }
+ }
+ }
+ if !commitDone {
+ graph.Commits = append(graph.Commits, RelationCommit)
+ }
+ return err
+}
+
+func (parser *Parser) releaseUnusedColors() {
+ if parser.firstInUse > -1 {
+ // Here we step through the old colors, searching for them in the
+ // "in-use" section of availableColors (that is, the colors between
+ // firstInUse and firstAvailable)
+ // Ensure that the benchmarks are not worsened with proposed changes
+ stepstaken := 0
+ position := parser.firstInUse
+ for _, color := range parser.oldColors {
+ if color == 0 {
+ continue
+ }
+ found := false
+ i := position
+ for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ {
+ colorToCheck := parser.availableColors[i]
+ if colorToCheck == color {
+ found = true
+ break
+ }
+ i = (i + 1) % len(parser.availableColors)
+ }
+ if !found {
+ // Duplicate color
+ continue
+ }
+ // Swap them around
+ parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position]
+ stepstaken++
+ position = (parser.firstInUse + stepstaken) % len(parser.availableColors)
+ if position == parser.firstAvailable || stepstaken == len(parser.availableColors) {
+ break
+ }
+ }
+ if stepstaken == len(parser.availableColors) {
+ parser.firstAvailable = -1
+ } else {
+ parser.firstAvailable = position
+ if parser.nextAvailable == -1 {
+ parser.nextAvailable = parser.firstAvailable
+ }
+ }
+ }
+}
+
+// ParseGlyphs parses the provided glyphs and sets the internal state
+func (parser *Parser) ParseGlyphs(glyphs []byte) {
+ // Clean state for parsing this row
+ parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs
+ parser.glyphs = parser.glyphs[0:0]
+ parser.flows, parser.oldFlows = parser.oldFlows, parser.flows
+ parser.flows = parser.flows[0:0]
+ parser.colors, parser.oldColors = parser.oldColors, parser.colors
+
+ // Ensure we have enough flows and colors
+ parser.colors = parser.colors[0:0]
+ for range glyphs {
+ parser.flows = append(parser.flows, 0)
+ parser.colors = append(parser.colors, 0)
+ }
+
+ // Copy the provided glyphs in to state.glyphs for safekeeping
+ parser.glyphs = append(parser.glyphs, glyphs...)
+
+ // release unused colors
+ parser.releaseUnusedColors()
+
+ for i := len(glyphs) - 1; i >= 0; i-- {
+ glyph := glyphs[i]
+ switch glyph {
+ case '|':
+ fallthrough
+ case '*':
+ parser.setUpFlow(i)
+ case '/':
+ parser.setOutFlow(i)
+ case '\\':
+ parser.setInFlow(i)
+ case '_':
+ parser.setRightFlow(i)
+ case '.':
+ fallthrough
+ case '-':
+ parser.setLeftFlow(i)
+ case ' ':
+ // no-op
+ default:
+ parser.newFlow(i)
+ }
+ }
+}
+
+func (parser *Parser) takePreviousFlow(i, j int) {
+ if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 {
+ parser.flows[i] = parser.oldFlows[j]
+ parser.oldFlows[j] = 0
+ parser.colors[i] = parser.oldColors[j]
+ parser.oldColors[j] = 0
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+func (parser *Parser) takeCurrentFlow(i, j int) {
+ if j < len(parser.flows) && parser.flows[j] > 0 {
+ parser.flows[i] = parser.flows[j]
+ parser.colors[i] = parser.colors[j]
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+func (parser *Parser) newFlow(i int) {
+ parser.maxFlow++
+ parser.flows[i] = parser.maxFlow
+
+ // Now give this flow a color
+ if parser.nextAvailable == -1 {
+ next := len(parser.availableColors)
+ if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors {
+ parser.nextAvailable = next
+ parser.firstAvailable = next
+ parser.availableColors = append(parser.availableColors, next+1)
+ }
+ }
+ parser.colors[i] = parser.availableColors[parser.nextAvailable]
+ if parser.firstInUse == -1 {
+ parser.firstInUse = parser.nextAvailable
+ }
+ parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable]
+
+ parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors)
+ parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors)
+
+ if parser.nextAvailable == parser.firstInUse {
+ parser.nextAvailable = parser.firstAvailable
+ }
+ if parser.nextAvailable == parser.firstInUse {
+ parser.nextAvailable = -1
+ parser.firstAvailable = -1
+ }
+}
+
+// setUpFlow handles '|' or '*'
+func (parser *Parser) setUpFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: '\? ' ' |' ' /'
+ // Current Row: ' | ' ' |' ' | '
+ if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' {
+ parser.takePreviousFlow(i, i-1)
+ } else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') {
+ parser.takePreviousFlow(i, i)
+ } else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' {
+ parser.takePreviousFlow(i, i+1)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setOutFlow handles '/'
+func (parser *Parser) setOutFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\'
+ // Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/'
+ if i+2 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') &&
+ (parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') &&
+ i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') {
+ parser.takePreviousFlow(i, i+2)
+ } else if i+1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' ||
+ parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') {
+ parser.takePreviousFlow(i, i+1)
+ if parser.oldGlyphs[i+1] == '/' {
+ parser.glyphs[i] = '|'
+ }
+ } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' {
+ parser.takePreviousFlow(i, i)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setInFlow handles '\'
+func (parser *Parser) setInFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: '| ' '-. ' '| ' '\ ' '/' '---'
+ // Current Row: '|\' ' \' ' \' ' \' '\' ' \ '
+ if i > 0 && i-1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') &&
+ (parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') {
+ parser.newFlow(i)
+ } else if i > 0 && i-1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' ||
+ parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') {
+ parser.takePreviousFlow(i, i-1)
+ if parser.oldGlyphs[i-1] == '\\' {
+ parser.glyphs[i] = '|'
+ }
+ } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' {
+ parser.takePreviousFlow(i, i)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setRightFlow handles '_'
+func (parser *Parser) setRightFlow(i int) {
+ // In preference order:
+ //
+ // Current Row: '__' '_/' '_|_' '_|/'
+ if i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') {
+ parser.takeCurrentFlow(i, i+1)
+ } else if i+2 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') &&
+ (parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') {
+ parser.takeCurrentFlow(i, i+2)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setLeftFlow handles '----.'
+func (parser *Parser) setLeftFlow(i int) {
+ if parser.glyphs[i] == '.' {
+ parser.newFlow(i)
+ } else if i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') {
+ parser.takeCurrentFlow(i, i+1)
+ } else {
+ parser.newFlow(i)
+ }
+}
diff --git a/modules/gitrepo/branch.go b/modules/gitrepo/branch.go
new file mode 100644
index 0000000..e13a4c8
--- /dev/null
+++ b/modules/gitrepo/branch.go
@@ -0,0 +1,49 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// GetBranchesByPath returns a branch by its path
+// if limit = 0 it will not limit
+func GetBranchesByPath(ctx context.Context, repo Repository, skip, limit int) ([]*git.Branch, int, error) {
+ gitRepo, err := OpenRepository(ctx, repo)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer gitRepo.Close()
+
+ return gitRepo.GetBranches(skip, limit)
+}
+
+func GetBranchCommitID(ctx context.Context, repo Repository, branch string) (string, error) {
+ gitRepo, err := OpenRepository(ctx, repo)
+ if err != nil {
+ return "", err
+ }
+ defer gitRepo.Close()
+
+ return gitRepo.GetBranchCommitID(branch)
+}
+
+// SetDefaultBranch sets default branch of repository.
+func SetDefaultBranch(ctx context.Context, repo Repository, name string) error {
+ _, _, err := git.NewCommand(ctx, "symbolic-ref", "HEAD").
+ AddDynamicArguments(git.BranchPrefix + name).
+ RunStdString(&git.RunOpts{Dir: repoPath(repo)})
+ return err
+}
+
+// GetDefaultBranch gets default branch of repository.
+func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) {
+ return git.GetDefaultBranch(ctx, repoPath(repo))
+}
+
+func GetWikiDefaultBranch(ctx context.Context, repo Repository) (string, error) {
+ return git.GetDefaultBranch(ctx, wikiPath(repo))
+}
diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go
new file mode 100644
index 0000000..d89f8f9
--- /dev/null
+++ b/modules/gitrepo/gitrepo.go
@@ -0,0 +1,103 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type Repository interface {
+ GetName() string
+ GetOwnerName() string
+}
+
+func repoPath(repo Repository) string {
+ return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".git")
+}
+
+func wikiPath(repo Repository) string {
+ return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".wiki.git")
+}
+
+// OpenRepository opens the repository at the given relative path with the provided context.
+func OpenRepository(ctx context.Context, repo Repository) (*git.Repository, error) {
+ return git.OpenRepository(ctx, repoPath(repo))
+}
+
+func OpenWikiRepository(ctx context.Context, repo Repository) (*git.Repository, error) {
+ return git.OpenRepository(ctx, wikiPath(repo))
+}
+
+// contextKey is a value for use with context.WithValue.
+type contextKey struct {
+ name string
+}
+
+// RepositoryContextKey is a context key. It is used with context.Value() to get the current Repository for the context
+var RepositoryContextKey = &contextKey{"repository"}
+
+// RepositoryFromContext attempts to get the repository from the context
+func repositoryFromContext(ctx context.Context, repo Repository) *git.Repository {
+ value := ctx.Value(RepositoryContextKey)
+ if value == nil {
+ return nil
+ }
+
+ if gitRepo, ok := value.(*git.Repository); ok && gitRepo != nil {
+ if gitRepo.Path == repoPath(repo) {
+ return gitRepo
+ }
+ }
+
+ return nil
+}
+
+type nopCloser func()
+
+func (nopCloser) Close() error { return nil }
+
+// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
+func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
+ gitRepo := repositoryFromContext(ctx, repo)
+ if gitRepo != nil {
+ return gitRepo, nopCloser(nil), nil
+ }
+
+ gitRepo, err := OpenRepository(ctx, repo)
+ return gitRepo, gitRepo, err
+}
+
+// repositoryFromContextPath attempts to get the repository from the context
+func repositoryFromContextPath(ctx context.Context, path string) *git.Repository {
+ value := ctx.Value(RepositoryContextKey)
+ if value == nil {
+ return nil
+ }
+
+ if repo, ok := value.(*git.Repository); ok && repo != nil {
+ if repo.Path == path {
+ return repo
+ }
+ }
+
+ return nil
+}
+
+// RepositoryFromContextOrOpenPath attempts to get the repository from the context or just opens it
+// Deprecated: Use RepositoryFromContextOrOpen instead
+func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) {
+ gitRepo := repositoryFromContextPath(ctx, path)
+ if gitRepo != nil {
+ return gitRepo, nopCloser(nil), nil
+ }
+
+ gitRepo, err := git.OpenRepository(ctx, path)
+ return gitRepo, gitRepo, err
+}
diff --git a/modules/gitrepo/walk.go b/modules/gitrepo/walk.go
new file mode 100644
index 0000000..8c672ea
--- /dev/null
+++ b/modules/gitrepo/walk.go
@@ -0,0 +1,15 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// WalkReferences walks all the references from the repository
+func WalkReferences(ctx context.Context, repo Repository, walkfn func(sha1, refname string) error) (int, error) {
+ return git.WalkShowRef(ctx, repoPath(repo), nil, 0, 0, walkfn)
+}
diff --git a/modules/graceful/context.go b/modules/graceful/context.go
new file mode 100644
index 0000000..c9c4ca4
--- /dev/null
+++ b/modules/graceful/context.go
@@ -0,0 +1,36 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package graceful
+
+import (
+ "context"
+)
+
+// Shutdown procedure:
+// * cancel ShutdownContext: the registered context consumers have time to do their cleanup (they could use the hammer context)
+// * cancel HammerContext: the all context consumers have limited time to do their cleanup (wait for a few seconds)
+// * cancel TerminateContext: the registered context consumers have time to do their cleanup (but they shouldn't use shutdown/hammer context anymore)
+// * cancel manager context
+// If the shutdown is triggered again during the shutdown procedure, the hammer context will be canceled immediately to force to shut down.
+
+// ShutdownContext returns a context.Context that is Done at shutdown
+// Callers using this context should ensure that they are registered as a running server
+// in order that they are waited for.
+func (g *Manager) ShutdownContext() context.Context {
+ return g.shutdownCtx
+}
+
+// HammerContext returns a context.Context that is Done at hammer
+// Callers using this context should ensure that they are registered as a running server
+// in order that they are waited for.
+func (g *Manager) HammerContext() context.Context {
+ return g.hammerCtx
+}
+
+// TerminateContext returns a context.Context that is Done at terminate
+// Callers using this context should ensure that they are registered as a terminating server
+// in order that they are waited for.
+func (g *Manager) TerminateContext() context.Context {
+ return g.terminateCtx
+}
diff --git a/modules/graceful/manager.go b/modules/graceful/manager.go
new file mode 100644
index 0000000..077eac6
--- /dev/null
+++ b/modules/graceful/manager.go
@@ -0,0 +1,260 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package graceful
+
+import (
+ "context"
+ "runtime/pprof"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type state uint8
+
+const (
+ stateInit state = iota
+ stateRunning
+ stateShuttingDown
+ stateTerminate
+)
+
+type RunCanceler interface {
+ Run()
+ Cancel()
+}
+
+// There are some places that could inherit sockets:
+//
+// * HTTP or HTTPS main listener
+// * HTTP or HTTPS install listener
+// * HTTP redirection fallback
+// * Builtin SSH listener
+//
+// If you add a new place you must increment this number
+// and add a function to call manager.InformCleanup if it's not going to be used
+const numberOfServersToCreate = 4
+
+var (
+ manager *Manager
+ initOnce sync.Once
+)
+
+// GetManager returns the Manager
+func GetManager() *Manager {
+ InitManager(context.Background())
+ return manager
+}
+
+// InitManager creates the graceful manager in the provided context
+func InitManager(ctx context.Context) {
+ initOnce.Do(func() {
+ manager = newGracefulManager(ctx)
+
+ // Set the process default context to the HammerContext
+ process.DefaultContext = manager.HammerContext()
+ })
+}
+
+// RunWithCancel helps to run a function with a custom context, the Cancel function will be called at shutdown
+// The Cancel function should stop the Run function in predictable time.
+func (g *Manager) RunWithCancel(rc RunCanceler) {
+ g.RunAtShutdown(context.Background(), rc.Cancel)
+ g.runningServerWaitGroup.Add(1)
+ defer g.runningServerWaitGroup.Done()
+ defer func() {
+ if err := recover(); err != nil {
+ log.Critical("PANIC during RunWithCancel: %v\nStacktrace: %s", err, log.Stack(2))
+ g.doShutdown()
+ }
+ }()
+ rc.Run()
+}
+
+// RunWithShutdownContext takes a function that has a context to watch for shutdown.
+// After the provided context is Done(), the main function must return once shutdown is complete.
+// (Optionally the HammerContext may be obtained and waited for however, this should be avoided if possible.)
+func (g *Manager) RunWithShutdownContext(run func(context.Context)) {
+ g.runningServerWaitGroup.Add(1)
+ defer g.runningServerWaitGroup.Done()
+ defer func() {
+ if err := recover(); err != nil {
+ log.Critical("PANIC during RunWithShutdownContext: %v\nStacktrace: %s", err, log.Stack(2))
+ g.doShutdown()
+ }
+ }()
+ ctx := g.ShutdownContext()
+ pprof.SetGoroutineLabels(ctx) // We don't have a label to restore back to but I think this is fine
+ run(ctx)
+}
+
+// RunAtTerminate adds to the terminate wait group and creates a go-routine to run the provided function at termination
+func (g *Manager) RunAtTerminate(terminate func()) {
+ g.terminateWaitGroup.Add(1)
+ g.lock.Lock()
+ defer g.lock.Unlock()
+ g.toRunAtTerminate = append(g.toRunAtTerminate,
+ func() {
+ defer g.terminateWaitGroup.Done()
+ defer func() {
+ if err := recover(); err != nil {
+ log.Critical("PANIC during RunAtTerminate: %v\nStacktrace: %s", err, log.Stack(2))
+ }
+ }()
+ terminate()
+ })
+}
+
+// RunAtShutdown creates a go-routine to run the provided function at shutdown
+func (g *Manager) RunAtShutdown(ctx context.Context, shutdown func()) {
+ g.lock.Lock()
+ defer g.lock.Unlock()
+ g.toRunAtShutdown = append(g.toRunAtShutdown,
+ func() {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Critical("PANIC during RunAtShutdown: %v\nStacktrace: %s", err, log.Stack(2))
+ }
+ }()
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ shutdown()
+ }
+ })
+}
+
+func (g *Manager) doShutdown() {
+ if !g.setStateTransition(stateRunning, stateShuttingDown) {
+ g.DoImmediateHammer()
+ return
+ }
+ g.lock.Lock()
+ g.shutdownCtxCancel()
+ atShutdownCtx := pprof.WithLabels(g.hammerCtx, pprof.Labels("gracefulLifecycle", "post-shutdown"))
+ pprof.SetGoroutineLabels(atShutdownCtx)
+ for _, fn := range g.toRunAtShutdown {
+ go fn()
+ }
+ g.lock.Unlock()
+
+ if setting.GracefulHammerTime >= 0 {
+ go g.doHammerTime(setting.GracefulHammerTime)
+ }
+ go func() {
+ g.runningServerWaitGroup.Wait()
+ // Mop up any remaining unclosed events.
+ g.doHammerTime(0)
+ <-time.After(1 * time.Second)
+ g.doTerminate()
+ g.terminateWaitGroup.Wait()
+ g.lock.Lock()
+ g.managerCtxCancel()
+ g.lock.Unlock()
+ }()
+}
+
+func (g *Manager) doHammerTime(d time.Duration) {
+ time.Sleep(d)
+ g.lock.Lock()
+ select {
+ case <-g.hammerCtx.Done():
+ default:
+ log.Warn("Setting Hammer condition")
+ g.hammerCtxCancel()
+ atHammerCtx := pprof.WithLabels(g.terminateCtx, pprof.Labels("gracefulLifecycle", "post-hammer"))
+ pprof.SetGoroutineLabels(atHammerCtx)
+ }
+ g.lock.Unlock()
+}
+
+func (g *Manager) doTerminate() {
+ if !g.setStateTransition(stateShuttingDown, stateTerminate) {
+ return
+ }
+ g.lock.Lock()
+ select {
+ case <-g.terminateCtx.Done():
+ default:
+ log.Warn("Terminating")
+ g.terminateCtxCancel()
+ atTerminateCtx := pprof.WithLabels(g.managerCtx, pprof.Labels("gracefulLifecycle", "post-terminate"))
+ pprof.SetGoroutineLabels(atTerminateCtx)
+
+ for _, fn := range g.toRunAtTerminate {
+ go fn()
+ }
+ }
+ g.lock.Unlock()
+}
+
+// IsChild returns if the current process is a child of previous Gitea process
+func (g *Manager) IsChild() bool {
+ return g.isChild
+}
+
+// IsShutdown returns a channel which will be closed at shutdown.
+// The order of closure is shutdown, hammer (potentially), terminate
+func (g *Manager) IsShutdown() <-chan struct{} {
+ return g.shutdownCtx.Done()
+}
+
+// IsHammer returns a channel which will be closed at hammer.
+// Servers running within the running server wait group should respond to IsHammer
+// if not shutdown already
+func (g *Manager) IsHammer() <-chan struct{} {
+ return g.hammerCtx.Done()
+}
+
+// ServerDone declares a running server done and subtracts one from the
+// running server wait group. Users probably do not want to call this
+// and should use one of the RunWithShutdown* functions
+func (g *Manager) ServerDone() {
+ g.runningServerWaitGroup.Done()
+}
+
+func (g *Manager) setStateTransition(old, new state) bool {
+ g.lock.Lock()
+ if g.state != old {
+ g.lock.Unlock()
+ return false
+ }
+ g.state = new
+ g.lock.Unlock()
+ return true
+}
+
+// InformCleanup tells the cleanup wait group that we have either taken a listener or will not be taking a listener.
+// At the moment the total number of servers (numberOfServersToCreate) are pre-defined as a const before global init,
+// so this function MUST be called if a server is not used.
+func (g *Manager) InformCleanup() {
+ g.createServerCond.L.Lock()
+ defer g.createServerCond.L.Unlock()
+ g.createdServer++
+ g.createServerCond.Signal()
+}
+
+// Done allows the manager to be viewed as a context.Context, it returns a channel that is closed when the server is finished terminating
+func (g *Manager) Done() <-chan struct{} {
+ return g.managerCtx.Done()
+}
+
+// Err allows the manager to be viewed as a context.Context done at Terminate
+func (g *Manager) Err() error {
+ return g.managerCtx.Err()
+}
+
+// Value allows the manager to be viewed as a context.Context done at Terminate
+func (g *Manager) Value(key any) any {
+ return g.managerCtx.Value(key)
+}
+
+// Deadline returns nil as there is no fixed Deadline for the manager, it allows the manager to be viewed as a context.Context
+func (g *Manager) Deadline() (deadline time.Time, ok bool) {
+ return g.managerCtx.Deadline()
+}
diff --git a/modules/graceful/manager_common.go b/modules/graceful/manager_common.go
new file mode 100644
index 0000000..892957e
--- /dev/null
+++ b/modules/graceful/manager_common.go
@@ -0,0 +1,108 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package graceful
+
+import (
+ "context"
+ "runtime/pprof"
+ "sync"
+ "time"
+)
+
+// FIXME: it seems that there is a bug when using systemd Type=notify: the "Install Page" (INSTALL_LOCK=false) doesn't notify properly.
+// At the moment, no idea whether it also affects Windows Service, or whether it's a regression bug. It needs to be investigated later.
+
+type systemdNotifyMsg string
+
+const (
+ readyMsg systemdNotifyMsg = "READY=1"
+ stoppingMsg systemdNotifyMsg = "STOPPING=1"
+ reloadingMsg systemdNotifyMsg = "RELOADING=1"
+ watchdogMsg systemdNotifyMsg = "WATCHDOG=1"
+)
+
+func statusMsg(msg string) systemdNotifyMsg {
+ return systemdNotifyMsg("STATUS=" + msg)
+}
+
+// Manager manages the graceful shutdown process
+type Manager struct {
+ ctx context.Context
+ isChild bool
+ forked bool
+ lock sync.RWMutex
+ state state
+ shutdownCtx context.Context
+ hammerCtx context.Context
+ terminateCtx context.Context
+ managerCtx context.Context
+ shutdownCtxCancel context.CancelFunc
+ hammerCtxCancel context.CancelFunc
+ terminateCtxCancel context.CancelFunc
+ managerCtxCancel context.CancelFunc
+ runningServerWaitGroup sync.WaitGroup
+ terminateWaitGroup sync.WaitGroup
+ createServerCond sync.Cond
+ createdServer int
+ shutdownRequested chan struct{}
+
+ toRunAtShutdown []func()
+ toRunAtTerminate []func()
+}
+
+func newGracefulManager(ctx context.Context) *Manager {
+ manager := &Manager{ctx: ctx, shutdownRequested: make(chan struct{})}
+ manager.createServerCond.L = &sync.Mutex{}
+ manager.prepare(ctx)
+ manager.start()
+ return manager
+}
+
+func (g *Manager) prepare(ctx context.Context) {
+ g.terminateCtx, g.terminateCtxCancel = context.WithCancel(ctx)
+ g.shutdownCtx, g.shutdownCtxCancel = context.WithCancel(ctx)
+ g.hammerCtx, g.hammerCtxCancel = context.WithCancel(ctx)
+ g.managerCtx, g.managerCtxCancel = context.WithCancel(ctx)
+
+ g.terminateCtx = pprof.WithLabels(g.terminateCtx, pprof.Labels("gracefulLifecycle", "with-terminate"))
+ g.shutdownCtx = pprof.WithLabels(g.shutdownCtx, pprof.Labels("gracefulLifecycle", "with-shutdown"))
+ g.hammerCtx = pprof.WithLabels(g.hammerCtx, pprof.Labels("gracefulLifecycle", "with-hammer"))
+ g.managerCtx = pprof.WithLabels(g.managerCtx, pprof.Labels("gracefulLifecycle", "with-manager"))
+
+ if !g.setStateTransition(stateInit, stateRunning) {
+ panic("invalid graceful manager state: transition from init to running failed")
+ }
+}
+
+// DoImmediateHammer causes an immediate hammer
+func (g *Manager) DoImmediateHammer() {
+ g.notify(statusMsg("Sending immediate hammer"))
+ g.doHammerTime(0 * time.Second)
+}
+
+// DoGracefulShutdown causes a graceful shutdown
+func (g *Manager) DoGracefulShutdown() {
+ g.lock.Lock()
+ select {
+ case <-g.shutdownRequested:
+ default:
+ close(g.shutdownRequested)
+ }
+ forked := g.forked
+ g.lock.Unlock()
+
+ if !forked {
+ g.notify(stoppingMsg)
+ } else {
+ g.notify(statusMsg("Shutting down after fork"))
+ }
+ g.doShutdown()
+}
+
+// RegisterServer registers the running of a listening server, in the case of unix this means that the parent process can now die.
+// Any call to RegisterServer must be matched by a call to ServerDone
+func (g *Manager) RegisterServer() {
+ KillParent()
+ g.runningServerWaitGroup.Add(1)
+}
diff --git a/modules/graceful/manager_unix.go b/modules/graceful/manager_unix.go
new file mode 100644
index 0000000..931b0f1
--- /dev/null
+++ b/modules/graceful/manager_unix.go
@@ -0,0 +1,201 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package graceful
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/signal"
+ "runtime/pprof"
+ "strconv"
+ "syscall"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful/releasereopen"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func pidMsg() systemdNotifyMsg {
+ return systemdNotifyMsg("MAINPID=" + strconv.Itoa(os.Getpid()))
+}
+
+// Notify systemd of status via the notify protocol
+func (g *Manager) notify(msg systemdNotifyMsg) {
+ conn, err := getNotifySocket()
+ if err != nil {
+ // the err is logged in getNotifySocket
+ return
+ }
+ if conn == nil {
+ return
+ }
+ defer conn.Close()
+
+ if _, err = conn.Write([]byte(msg)); err != nil {
+ log.Warn("Failed to notify NOTIFY_SOCKET: %v", err)
+ return
+ }
+}
+
+func (g *Manager) start() {
+ // Now label this and all goroutines created by this goroutine with the gracefulLifecycle manager
+ pprof.SetGoroutineLabels(g.managerCtx)
+ defer pprof.SetGoroutineLabels(g.ctx)
+
+ g.isChild = len(os.Getenv(listenFDsEnv)) > 0 && os.Getppid() > 1
+
+ g.notify(statusMsg("Starting Gitea"))
+ g.notify(pidMsg())
+ go g.handleSignals(g.managerCtx)
+
+ // Handle clean up of unused provided listeners and delayed start-up
+ startupDone := make(chan struct{})
+ go func() {
+ defer func() {
+ close(startupDone)
+ // Close the unused listeners
+ closeProvidedListeners()
+ }()
+ // Wait for all servers to be created
+ g.createServerCond.L.Lock()
+ for {
+ if g.createdServer >= numberOfServersToCreate {
+ g.createServerCond.L.Unlock()
+ g.notify(readyMsg)
+ return
+ }
+ select {
+ case <-g.IsShutdown():
+ g.createServerCond.L.Unlock()
+ return
+ default:
+ }
+ g.createServerCond.Wait()
+ }
+ }()
+ if setting.StartupTimeout > 0 {
+ go func() {
+ select {
+ case <-startupDone:
+ return
+ case <-g.IsShutdown():
+ g.createServerCond.Signal()
+ return
+ case <-time.After(setting.StartupTimeout):
+ log.Error("Startup took too long! Shutting down")
+ g.notify(statusMsg("Startup took too long! Shutting down"))
+ g.notify(stoppingMsg)
+ g.doShutdown()
+ }
+ }()
+ }
+}
+
+func (g *Manager) handleSignals(ctx context.Context) {
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Graceful: HandleSignals", process.SystemProcessType, true)
+ defer finished()
+
+ signalChannel := make(chan os.Signal, 1)
+
+ signal.Notify(
+ signalChannel,
+ syscall.SIGHUP,
+ syscall.SIGUSR1,
+ syscall.SIGUSR2,
+ syscall.SIGINT,
+ syscall.SIGTERM,
+ syscall.SIGTSTP,
+ )
+
+ watchdogTimeout := getWatchdogTimeout()
+ t := &time.Ticker{}
+ if watchdogTimeout != 0 {
+ g.notify(watchdogMsg)
+ t = time.NewTicker(watchdogTimeout / 2)
+ }
+
+ pid := syscall.Getpid()
+ for {
+ select {
+ case sig := <-signalChannel:
+ switch sig {
+ case syscall.SIGHUP:
+ log.Info("PID: %d. Received SIGHUP. Attempting GracefulRestart...", pid)
+ g.DoGracefulRestart()
+ case syscall.SIGUSR1:
+ log.Warn("PID %d. Received SIGUSR1. Releasing and reopening logs", pid)
+ g.notify(statusMsg("Releasing and reopening logs"))
+ if err := releasereopen.GetManager().ReleaseReopen(); err != nil {
+ log.Error("Error whilst releasing and reopening logs: %v", err)
+ }
+ case syscall.SIGUSR2:
+ log.Warn("PID %d. Received SIGUSR2. Hammering...", pid)
+ g.DoImmediateHammer()
+ case syscall.SIGINT:
+ log.Warn("PID %d. Received SIGINT. Shutting down...", pid)
+ g.DoGracefulShutdown()
+ case syscall.SIGTERM:
+ log.Warn("PID %d. Received SIGTERM. Shutting down...", pid)
+ g.DoGracefulShutdown()
+ case syscall.SIGTSTP:
+ log.Info("PID %d. Received SIGTSTP.", pid)
+ default:
+ log.Info("PID %d. Received %v.", pid, sig)
+ }
+ case <-t.C:
+ g.notify(watchdogMsg)
+ case <-ctx.Done():
+ log.Warn("PID: %d. Background context for manager closed - %v - Shutting down...", pid, ctx.Err())
+ g.DoGracefulShutdown()
+ return
+ }
+ }
+}
+
+func (g *Manager) doFork() error {
+ g.lock.Lock()
+ if g.forked {
+ g.lock.Unlock()
+ return errors.New("another process already forked. Ignoring this one")
+ }
+ g.forked = true
+ g.lock.Unlock()
+
+ g.notify(reloadingMsg)
+
+ // We need to move the file logs to append pids
+ setting.RestartLogsWithPIDSuffix()
+
+ _, err := RestartProcess()
+
+ return err
+}
+
+// DoGracefulRestart causes a graceful restart
+func (g *Manager) DoGracefulRestart() {
+ if setting.GracefulRestartable {
+ log.Info("PID: %d. Forking...", os.Getpid())
+ err := g.doFork()
+ if err != nil {
+ if err.Error() == "another process already forked. Ignoring this one" {
+ g.DoImmediateHammer()
+ } else {
+ log.Error("Error whilst forking from PID: %d : %v", os.Getpid(), err)
+ }
+ }
+ // doFork calls RestartProcess which starts a new Gitea process, so this parent process needs to exit
+ // Otherwise some resources (eg: leveldb lock) will be held by this parent process and the new process will fail to start
+ log.Info("PID: %d. Shutting down after forking ...", os.Getpid())
+ g.doShutdown()
+ } else {
+ log.Info("PID: %d. Not set restartable. Shutting down...", os.Getpid())
+ g.notify(stoppingMsg)
+ g.doShutdown()
+ }
+}
diff --git a/modules/graceful/manager_windows.go b/modules/graceful/manager_windows.go
new file mode 100644
index 0000000..bee4438
--- /dev/null
+++ b/modules/graceful/manager_windows.go
@@ -0,0 +1,190 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+// This code is heavily inspired by the archived gofacebook/gracenet/net.go handler
+
+//go:build windows
+
+package graceful
+
+import (
+ "os"
+ "runtime/pprof"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "golang.org/x/sys/windows/svc"
+ "golang.org/x/sys/windows/svc/debug"
+)
+
+// WindowsServiceName is the name of the Windows service
+var WindowsServiceName = "gitea"
+
+const (
+ hammerCode = 128
+ hammerCmd = svc.Cmd(hammerCode)
+ acceptHammerCode = svc.Accepted(hammerCode)
+)
+
+func (g *Manager) start() {
+ // Now label this and all goroutines created by this goroutine with the gracefulLifecycle manager
+ pprof.SetGoroutineLabels(g.managerCtx)
+ defer pprof.SetGoroutineLabels(g.ctx)
+
+ if skip, _ := strconv.ParseBool(os.Getenv("SKIP_MINWINSVC")); skip {
+ log.Trace("Skipping SVC check as SKIP_MINWINSVC is set")
+ return
+ }
+
+ // Make SVC process
+ run := svc.Run
+
+ //lint:ignore SA1019 We use IsAnInteractiveSession because IsWindowsService has a different permissions profile
+ isAnInteractiveSession, err := svc.IsAnInteractiveSession() //nolint:staticcheck
+ if err != nil {
+ log.Error("Unable to ascertain if running as an Windows Service: %v", err)
+ return
+ }
+ if isAnInteractiveSession {
+ log.Trace("Not running a service ... using the debug SVC manager")
+ run = debug.Run
+ }
+ go func() {
+ _ = run(WindowsServiceName, g)
+ }()
+}
+
+// Execute makes Manager implement svc.Handler
+func (g *Manager) Execute(args []string, changes <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
+ if setting.StartupTimeout > 0 {
+ status <- svc.Status{State: svc.StartPending, WaitHint: uint32(setting.StartupTimeout / time.Millisecond)}
+ } else {
+ status <- svc.Status{State: svc.StartPending}
+ }
+
+ log.Trace("Awaiting server start-up")
+ // Now need to wait for everything to start...
+ if !g.awaitServer(setting.StartupTimeout) {
+ log.Trace("... start-up failed ... Stopped")
+ return false, 1
+ }
+
+ log.Trace("Sending Running state to SVC")
+
+ // We need to implement some way of svc.AcceptParamChange/svc.ParamChange
+ status <- svc.Status{
+ State: svc.Running,
+ Accepts: svc.AcceptStop | svc.AcceptShutdown | acceptHammerCode,
+ }
+
+ log.Trace("Started")
+
+ waitTime := 30 * time.Second
+
+loop:
+ for {
+ select {
+ case <-g.ctx.Done():
+ log.Trace("Shutting down")
+ g.DoGracefulShutdown()
+ waitTime += setting.GracefulHammerTime
+ break loop
+ case <-g.shutdownRequested:
+ log.Trace("Shutting down")
+ waitTime += setting.GracefulHammerTime
+ break loop
+ case change := <-changes:
+ switch change.Cmd {
+ case svc.Interrogate:
+ log.Trace("SVC sent interrogate")
+ status <- change.CurrentStatus
+ case svc.Stop, svc.Shutdown:
+ log.Trace("SVC requested shutdown - shutting down")
+ g.DoGracefulShutdown()
+ waitTime += setting.GracefulHammerTime
+ break loop
+ case hammerCode:
+ log.Trace("SVC requested hammer - shutting down and hammering immediately")
+ g.DoGracefulShutdown()
+ g.DoImmediateHammer()
+ break loop
+ default:
+ log.Debug("Unexpected control request: %v", change.Cmd)
+ }
+ }
+ }
+
+ log.Trace("Sending StopPending state to SVC")
+ status <- svc.Status{
+ State: svc.StopPending,
+ WaitHint: uint32(waitTime / time.Millisecond),
+ }
+
+hammerLoop:
+ for {
+ select {
+ case change := <-changes:
+ switch change.Cmd {
+ case svc.Interrogate:
+ log.Trace("SVC sent interrogate")
+ status <- change.CurrentStatus
+ case svc.Stop, svc.Shutdown, hammerCmd:
+ log.Trace("SVC requested hammer - hammering immediately")
+ g.DoImmediateHammer()
+ break hammerLoop
+ default:
+ log.Debug("Unexpected control request: %v", change.Cmd)
+ }
+ case <-g.hammerCtx.Done():
+ break hammerLoop
+ }
+ }
+
+ log.Trace("Stopped")
+ return false, 0
+}
+
+func (g *Manager) awaitServer(limit time.Duration) bool {
+ c := make(chan struct{})
+ go func() {
+ g.createServerCond.L.Lock()
+ for {
+ if g.createdServer >= numberOfServersToCreate {
+ g.createServerCond.L.Unlock()
+ close(c)
+ return
+ }
+ select {
+ case <-g.IsShutdown():
+ g.createServerCond.L.Unlock()
+ return
+ default:
+ }
+ g.createServerCond.Wait()
+ }
+ }()
+
+ var tc <-chan time.Time
+ if limit > 0 {
+ tc = time.After(limit)
+ }
+ select {
+ case <-c:
+ return true // completed normally
+ case <-tc:
+ return false // timed out
+ case <-g.IsShutdown():
+ g.createServerCond.Signal()
+ return false
+ }
+}
+
+func (g *Manager) notify(msg systemdNotifyMsg) {
+ // Windows doesn't use systemd to notify
+}
+
+func KillParent() {
+ // Windows doesn't need to "kill parent" because there is no graceful restart
+}
diff --git a/modules/graceful/net_unix.go b/modules/graceful/net_unix.go
new file mode 100644
index 0000000..796e005
--- /dev/null
+++ b/modules/graceful/net_unix.go
@@ -0,0 +1,321 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This code is heavily inspired by the archived gofacebook/gracenet/net.go handler
+
+//go:build !windows
+
+package graceful
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ listenFDsEnv = "LISTEN_FDS"
+ startFD = 3
+ unlinkFDsEnv = "GITEA_UNLINK_FDS"
+
+ notifySocketEnv = "NOTIFY_SOCKET"
+ watchdogTimeoutEnv = "WATCHDOG_USEC"
+)
+
+// In order to keep the working directory the same as when we started we record
+// it at startup.
+var originalWD, _ = os.Getwd()
+
+var (
+ once = sync.Once{}
+ mutex = sync.Mutex{}
+
+ providedListenersToUnlink = []bool{}
+ activeListenersToUnlink = []bool{}
+ providedListeners = []net.Listener{}
+ activeListeners = []net.Listener{}
+
+ notifySocketAddr string
+ watchdogTimeout time.Duration
+)
+
+func getProvidedFDs() (savedErr error) {
+ // Only inherit the provided FDS once but we will save the error so that repeated calls to this function will return the same error
+ once.Do(func() {
+ mutex.Lock()
+ defer mutex.Unlock()
+ // now handle some additional systemd provided things
+ notifySocketAddr = os.Getenv(notifySocketEnv)
+ if notifySocketAddr != "" {
+ log.Debug("Systemd Notify Socket provided: %s", notifySocketAddr)
+ savedErr = os.Unsetenv(notifySocketEnv)
+ if savedErr != nil {
+ log.Warn("Unable to Unset the NOTIFY_SOCKET environment variable: %v", savedErr)
+ return
+ }
+ // FIXME: We don't handle WATCHDOG_PID
+ timeoutStr := os.Getenv(watchdogTimeoutEnv)
+ if timeoutStr != "" {
+ savedErr = os.Unsetenv(watchdogTimeoutEnv)
+ if savedErr != nil {
+ log.Warn("Unable to Unset the WATCHDOG_USEC environment variable: %v", savedErr)
+ return
+ }
+
+ s, err := strconv.ParseInt(timeoutStr, 10, 64)
+ if err != nil {
+ log.Error("Unable to parse the provided WATCHDOG_USEC: %v", err)
+ savedErr = fmt.Errorf("unable to parse the provided WATCHDOG_USEC: %w", err)
+ return
+ }
+ if s <= 0 {
+ log.Error("Unable to parse the provided WATCHDOG_USEC: %s should be a positive number", timeoutStr)
+ savedErr = fmt.Errorf("unable to parse the provided WATCHDOG_USEC: %s should be a positive number", timeoutStr)
+ return
+ }
+ watchdogTimeout = time.Duration(s) * time.Microsecond
+ }
+ } else {
+ log.Trace("No Systemd Notify Socket provided")
+ }
+
+ numFDs := os.Getenv(listenFDsEnv)
+ if numFDs == "" {
+ return
+ }
+ n, err := strconv.Atoi(numFDs)
+ if err != nil {
+ savedErr = fmt.Errorf("%s is not a number: %s. Err: %w", listenFDsEnv, numFDs, err)
+ return
+ }
+
+ fdsToUnlinkStr := strings.Split(os.Getenv(unlinkFDsEnv), ",")
+ providedListenersToUnlink = make([]bool, n)
+ for _, fdStr := range fdsToUnlinkStr {
+ i, err := strconv.Atoi(fdStr)
+ if err != nil || i < 0 || i >= n {
+ continue
+ }
+ providedListenersToUnlink[i] = true
+ }
+
+ for i := startFD; i < n+startFD; i++ {
+ file := os.NewFile(uintptr(i), fmt.Sprintf("listener_FD%d", i))
+
+ l, err := net.FileListener(file)
+ if err == nil {
+ // Close the inherited file if it's a listener
+ if err = file.Close(); err != nil {
+ savedErr = fmt.Errorf("error closing provided socket fd %d: %w", i, err)
+ return
+ }
+ providedListeners = append(providedListeners, l)
+ continue
+ }
+
+ // If needed we can handle packetconns here.
+ savedErr = fmt.Errorf("Error getting provided socket fd %d: %w", i, err)
+ return
+ }
+ })
+ return savedErr
+}
+
+// closeProvidedListeners closes all unused provided listeners.
+func closeProvidedListeners() {
+ mutex.Lock()
+ defer mutex.Unlock()
+ for _, l := range providedListeners {
+ err := l.Close()
+ if err != nil {
+ log.Error("Error in closing unused provided listener: %v", err)
+ }
+ }
+ providedListeners = []net.Listener{}
+}
+
+// DefaultGetListener obtains a listener for the stream-oriented local network address:
+// "tcp", "tcp4", "tcp6", "unix" or "unixpacket".
+func DefaultGetListener(network, address string) (net.Listener, error) {
+ // Add a deferral to say that we've tried to grab a listener
+ defer GetManager().InformCleanup()
+ switch network {
+ case "tcp", "tcp4", "tcp6":
+ tcpAddr, err := net.ResolveTCPAddr(network, address)
+ if err != nil {
+ return nil, err
+ }
+ return GetListenerTCP(network, tcpAddr)
+ case "unix", "unixpacket":
+ unixAddr, err := net.ResolveUnixAddr(network, address)
+ if err != nil {
+ return nil, err
+ }
+ return GetListenerUnix(network, unixAddr)
+ default:
+ return nil, net.UnknownNetworkError(network)
+ }
+}
+
+// GetListenerTCP announces on the local network address. The network must be:
+// "tcp", "tcp4" or "tcp6". It returns a provided net.Listener for the
+// matching network and address, or creates a new one using net.ListenTCP.
+func GetListenerTCP(network string, address *net.TCPAddr) (*net.TCPListener, error) {
+ if err := getProvidedFDs(); err != nil {
+ return nil, err
+ }
+
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ // look for a provided listener
+ for i, l := range providedListeners {
+ if isSameAddr(l.Addr(), address) {
+ providedListeners = append(providedListeners[:i], providedListeners[i+1:]...)
+ needsUnlink := providedListenersToUnlink[i]
+ providedListenersToUnlink = append(providedListenersToUnlink[:i], providedListenersToUnlink[i+1:]...)
+
+ activeListeners = append(activeListeners, l)
+ activeListenersToUnlink = append(activeListenersToUnlink, needsUnlink)
+ return l.(*net.TCPListener), nil
+ }
+ }
+
+ // no provided listener for this address -> make a fresh listener
+ l, err := net.ListenTCP(network, address)
+ if err != nil {
+ return nil, err
+ }
+ activeListeners = append(activeListeners, l)
+ activeListenersToUnlink = append(activeListenersToUnlink, false)
+ return l, nil
+}
+
+// GetListenerUnix announces on the local network address. The network must be:
+// "unix" or "unixpacket". It returns a provided net.Listener for the
+// matching network and address, or creates a new one using net.ListenUnix.
+func GetListenerUnix(network string, address *net.UnixAddr) (*net.UnixListener, error) {
+ if err := getProvidedFDs(); err != nil {
+ return nil, err
+ }
+
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ // look for a provided listener
+ for i, l := range providedListeners {
+ if isSameAddr(l.Addr(), address) {
+ providedListeners = append(providedListeners[:i], providedListeners[i+1:]...)
+ needsUnlink := providedListenersToUnlink[i]
+ providedListenersToUnlink = append(providedListenersToUnlink[:i], providedListenersToUnlink[i+1:]...)
+
+ activeListenersToUnlink = append(activeListenersToUnlink, needsUnlink)
+ activeListeners = append(activeListeners, l)
+ unixListener := l.(*net.UnixListener)
+ if needsUnlink {
+ unixListener.SetUnlinkOnClose(true)
+ }
+ return unixListener, nil
+ }
+ }
+
+ // make a fresh listener
+ if err := util.Remove(address.Name); err != nil && !os.IsNotExist(err) {
+ return nil, fmt.Errorf("Failed to remove unix socket %s: %w", address.Name, err)
+ }
+
+ l, err := net.ListenUnix(network, address)
+ if err != nil {
+ return nil, err
+ }
+
+ fileMode := os.FileMode(setting.UnixSocketPermission)
+ if err = os.Chmod(address.Name, fileMode); err != nil {
+ return nil, fmt.Errorf("Failed to set permission of unix socket to %s: %w", fileMode.String(), err)
+ }
+
+ activeListeners = append(activeListeners, l)
+ activeListenersToUnlink = append(activeListenersToUnlink, true)
+ return l, nil
+}
+
+func isSameAddr(a1, a2 net.Addr) bool {
+ // If the addresses are not on the same network fail.
+ if a1.Network() != a2.Network() {
+ return false
+ }
+
+ // If the two addresses have the same string representation they're equal
+ a1s := a1.String()
+ a2s := a2.String()
+ if a1s == a2s {
+ return true
+ }
+
+ // This allows for ipv6 vs ipv4 local addresses to compare as equal. This
+ // scenario is common when listening on localhost.
+ const ipv6prefix = "[::]"
+ a1s = strings.TrimPrefix(a1s, ipv6prefix)
+ a2s = strings.TrimPrefix(a2s, ipv6prefix)
+ const ipv4prefix = "0.0.0.0"
+ a1s = strings.TrimPrefix(a1s, ipv4prefix)
+ a2s = strings.TrimPrefix(a2s, ipv4prefix)
+ return a1s == a2s
+}
+
+func getActiveListeners() []net.Listener {
+ mutex.Lock()
+ defer mutex.Unlock()
+ listeners := make([]net.Listener, len(activeListeners))
+ copy(listeners, activeListeners)
+ return listeners
+}
+
+func getActiveListenersToUnlink() []bool {
+ mutex.Lock()
+ defer mutex.Unlock()
+ listenersToUnlink := make([]bool, len(activeListenersToUnlink))
+ copy(listenersToUnlink, activeListenersToUnlink)
+ return listenersToUnlink
+}
+
+func getNotifySocket() (*net.UnixConn, error) {
+ if err := getProvidedFDs(); err != nil {
+ // This error will be logged elsewhere
+ return nil, nil
+ }
+
+ if notifySocketAddr == "" {
+ return nil, nil
+ }
+
+ socketAddr := &net.UnixAddr{
+ Name: notifySocketAddr,
+ Net: "unixgram",
+ }
+
+ notifySocket, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
+ if err != nil {
+ log.Warn("failed to dial NOTIFY_SOCKET %s: %v", socketAddr, err)
+ return nil, err
+ }
+
+ return notifySocket, nil
+}
+
+func getWatchdogTimeout() time.Duration {
+ if err := getProvidedFDs(); err != nil {
+ // This error will be logged elsewhere
+ return 0
+ }
+
+ return watchdogTimeout
+}
diff --git a/modules/graceful/net_windows.go b/modules/graceful/net_windows.go
new file mode 100644
index 0000000..9667bd4
--- /dev/null
+++ b/modules/graceful/net_windows.go
@@ -0,0 +1,19 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This code is heavily inspired by the archived gofacebook/gracenet/net.go handler
+
+//go:build windows
+
+package graceful
+
+import "net"
+
+// DefaultGetListener obtains a listener for the local network address.
+// On windows this is basically just a shim around net.Listen.
+func DefaultGetListener(network, address string) (net.Listener, error) {
+ // Add a deferral to say that we've tried to grab a listener
+ defer GetManager().InformCleanup()
+
+ return net.Listen(network, address)
+}
diff --git a/modules/graceful/releasereopen/releasereopen.go b/modules/graceful/releasereopen/releasereopen.go
new file mode 100644
index 0000000..de5b07c
--- /dev/null
+++ b/modules/graceful/releasereopen/releasereopen.go
@@ -0,0 +1,61 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package releasereopen
+
+import (
+ "errors"
+ "sync"
+)
+
+type ReleaseReopener interface {
+ ReleaseReopen() error
+}
+
+type Manager struct {
+ mu sync.Mutex
+ counter int64
+
+ releaseReopeners map[int64]ReleaseReopener
+}
+
+func (r *Manager) Register(rr ReleaseReopener) (cancel func()) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.counter++
+ currentCounter := r.counter
+ r.releaseReopeners[r.counter] = rr
+
+ return func() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ delete(r.releaseReopeners, currentCounter)
+ }
+}
+
+func (r *Manager) ReleaseReopen() error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ var errs []error
+ for _, rr := range r.releaseReopeners {
+ if err := rr.ReleaseReopen(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func GetManager() *Manager {
+ return manager
+}
+
+func NewManager() *Manager {
+ return &Manager{
+ releaseReopeners: make(map[int64]ReleaseReopener),
+ }
+}
+
+var manager = NewManager()
diff --git a/modules/graceful/releasereopen/releasereopen_test.go b/modules/graceful/releasereopen/releasereopen_test.go
new file mode 100644
index 0000000..6ab9f95
--- /dev/null
+++ b/modules/graceful/releasereopen/releasereopen_test.go
@@ -0,0 +1,44 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package releasereopen
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testReleaseReopener struct {
+ count int
+}
+
+func (t *testReleaseReopener) ReleaseReopen() error {
+ t.count++
+ return nil
+}
+
+func TestManager(t *testing.T) {
+ m := NewManager()
+
+ t1 := &testReleaseReopener{}
+ t2 := &testReleaseReopener{}
+ t3 := &testReleaseReopener{}
+
+ _ = m.Register(t1)
+ c2 := m.Register(t2)
+ _ = m.Register(t3)
+
+ require.NoError(t, m.ReleaseReopen())
+ assert.EqualValues(t, 1, t1.count)
+ assert.EqualValues(t, 1, t2.count)
+ assert.EqualValues(t, 1, t3.count)
+
+ c2()
+
+ require.NoError(t, m.ReleaseReopen())
+ assert.EqualValues(t, 2, t1.count)
+ assert.EqualValues(t, 1, t2.count)
+ assert.EqualValues(t, 2, t3.count)
+}
diff --git a/modules/graceful/restart_unix.go b/modules/graceful/restart_unix.go
new file mode 100644
index 0000000..98d5c5c
--- /dev/null
+++ b/modules/graceful/restart_unix.go
@@ -0,0 +1,115 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This code is heavily inspired by the archived gofacebook/gracenet/net.go handler
+
+//go:build !windows
+
+package graceful
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+var killParent sync.Once
+
+// KillParent sends the kill signal to the parent process if we are a child
+func KillParent() {
+ killParent.Do(func() {
+ if GetManager().IsChild() {
+ ppid := syscall.Getppid()
+ if ppid > 1 {
+ _ = syscall.Kill(ppid, syscall.SIGTERM)
+ }
+ }
+ })
+}
+
+// RestartProcess starts a new process passing it the active listeners. It
+// doesn't fork, but starts a new process using the same environment and
+// arguments as when it was originally started. This allows for a newly
+// deployed binary to be started. It returns the pid of the newly started
+// process when successful.
+func RestartProcess() (int, error) {
+ listeners := getActiveListeners()
+
+ // Extract the fds from the listeners.
+ files := make([]*os.File, len(listeners))
+ for i, l := range listeners {
+ var err error
+ // Now, all our listeners actually have File() functions so instead of
+ // individually casting we just use a hacky interface
+ files[i], err = l.(filer).File()
+ if err != nil {
+ return 0, err
+ }
+
+ if unixListener, ok := l.(*net.UnixListener); ok {
+ unixListener.SetUnlinkOnClose(false)
+ }
+ // Remember to close these at the end.
+ defer func(i int) {
+ _ = files[i].Close()
+ }(i)
+ }
+
+ // Use the original binary location. This works with symlinks such that if
+ // the file it points to has been changed we will use the updated symlink.
+ argv0, err := exec.LookPath(os.Args[0])
+ if err != nil {
+ return 0, err
+ }
+
+ // Pass on the environment and replace the old count key with the new one.
+ var env []string
+ for _, v := range os.Environ() {
+ if !strings.HasPrefix(v, listenFDsEnv+"=") {
+ env = append(env, v)
+ }
+ }
+ env = append(env, fmt.Sprintf("%s=%d", listenFDsEnv, len(listeners)))
+
+ if notifySocketAddr != "" {
+ env = append(env, fmt.Sprintf("%s=%s", notifySocketEnv, notifySocketAddr))
+ }
+
+ if watchdogTimeout != 0 {
+ watchdogStr := strconv.FormatInt(int64(watchdogTimeout/time.Millisecond), 10)
+ env = append(env, fmt.Sprintf("%s=%s", watchdogTimeoutEnv, watchdogStr))
+ }
+
+ sb := &strings.Builder{}
+ for i, unlink := range getActiveListenersToUnlink() {
+ if !unlink {
+ continue
+ }
+ _, _ = sb.WriteString(strconv.Itoa(i))
+ _, _ = sb.WriteString(",")
+ }
+ unlinkStr := sb.String()
+ if len(unlinkStr) > 0 {
+ unlinkStr = unlinkStr[:len(unlinkStr)-1]
+ env = append(env, fmt.Sprintf("%s=%s", unlinkFDsEnv, unlinkStr))
+ }
+
+ allFiles := append([]*os.File{os.Stdin, os.Stdout, os.Stderr}, files...)
+ process, err := os.StartProcess(argv0, os.Args, &os.ProcAttr{
+ Dir: originalWD,
+ Env: env,
+ Files: allFiles,
+ })
+ if err != nil {
+ return 0, err
+ }
+ processPid := process.Pid
+ _ = process.Release() // no wait, so release
+ return processPid, nil
+}
diff --git a/modules/graceful/server.go b/modules/graceful/server.go
new file mode 100644
index 0000000..2525a83
--- /dev/null
+++ b/modules/graceful/server.go
@@ -0,0 +1,284 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This code is highly inspired by endless go
+
+package graceful
+
+import (
+ "crypto/tls"
+ "net"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/proxyprotocol"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// GetListener returns a net listener
+// This determines the implementation of net.Listener which the server will use,
+// so that downstreams could provide their own Listener, such as with a hidden service or a p2p network
+var GetListener = DefaultGetListener
+
+// ServeFunction represents a listen.Accept loop
+type ServeFunction = func(net.Listener) error
+
+// Server represents our graceful server
+type Server struct {
+ network string
+ address string
+ listener net.Listener
+ wg sync.WaitGroup
+ state state
+ lock *sync.RWMutex
+ BeforeBegin func(network, address string)
+ OnShutdown func()
+ PerWriteTimeout time.Duration
+ PerWritePerKbTimeout time.Duration
+}
+
+// NewServer creates a server on network at provided address
+func NewServer(network, address, name string) *Server {
+ if GetManager().IsChild() {
+ log.Info("Restarting new %s server: %s:%s on PID: %d", name, network, address, os.Getpid())
+ } else {
+ log.Info("Starting new %s server: %s:%s on PID: %d", name, network, address, os.Getpid())
+ }
+ srv := &Server{
+ wg: sync.WaitGroup{},
+ state: stateInit,
+ lock: &sync.RWMutex{},
+ network: network,
+ address: address,
+ PerWriteTimeout: setting.PerWriteTimeout,
+ PerWritePerKbTimeout: setting.PerWritePerKbTimeout,
+ }
+
+ srv.BeforeBegin = func(network, addr string) {
+ log.Debug("Starting server on %s:%s (PID: %d)", network, addr, syscall.Getpid())
+ }
+
+ return srv
+}
+
+// ListenAndServe listens on the provided network address and then calls Serve
+// to handle requests on incoming connections.
+func (srv *Server) ListenAndServe(serve ServeFunction, useProxyProtocol bool) error {
+ go srv.awaitShutdown()
+
+ listener, err := GetListener(srv.network, srv.address)
+ if err != nil {
+ log.Error("Unable to GetListener: %v", err)
+ return err
+ }
+
+ // we need to wrap the listener to take account of our lifecycle
+ listener = newWrappedListener(listener, srv)
+
+ // Now we need to take account of ProxyProtocol settings...
+ if useProxyProtocol {
+ listener = &proxyprotocol.Listener{
+ Listener: listener,
+ ProxyHeaderTimeout: setting.ProxyProtocolHeaderTimeout,
+ AcceptUnknown: setting.ProxyProtocolAcceptUnknown,
+ }
+ }
+ srv.listener = listener
+
+ srv.BeforeBegin(srv.network, srv.address)
+
+ return srv.Serve(serve)
+}
+
+// ListenAndServeTLSConfig listens on the provided network address and then calls
+// Serve to handle requests on incoming TLS connections.
+func (srv *Server) ListenAndServeTLSConfig(tlsConfig *tls.Config, serve ServeFunction, useProxyProtocol, proxyProtocolTLSBridging bool) error {
+ go srv.awaitShutdown()
+
+ if tlsConfig.MinVersion == 0 {
+ tlsConfig.MinVersion = tls.VersionTLS12
+ }
+
+ listener, err := GetListener(srv.network, srv.address)
+ if err != nil {
+ log.Error("Unable to get Listener: %v", err)
+ return err
+ }
+
+ // we need to wrap the listener to take account of our lifecycle
+ listener = newWrappedListener(listener, srv)
+
+ // Now we need to take account of ProxyProtocol settings... If we're not bridging then we expect that the proxy will forward the connection to us
+ if useProxyProtocol && !proxyProtocolTLSBridging {
+ listener = &proxyprotocol.Listener{
+ Listener: listener,
+ ProxyHeaderTimeout: setting.ProxyProtocolHeaderTimeout,
+ AcceptUnknown: setting.ProxyProtocolAcceptUnknown,
+ }
+ }
+
+ // Now handle the tls protocol
+ listener = tls.NewListener(listener, tlsConfig)
+
+ // Now if we're bridging then we need the proxy to tell us who we're bridging for...
+ if useProxyProtocol && proxyProtocolTLSBridging {
+ listener = &proxyprotocol.Listener{
+ Listener: listener,
+ ProxyHeaderTimeout: setting.ProxyProtocolHeaderTimeout,
+ AcceptUnknown: setting.ProxyProtocolAcceptUnknown,
+ }
+ }
+
+ srv.listener = listener
+ srv.BeforeBegin(srv.network, srv.address)
+
+ return srv.Serve(serve)
+}
+
+// Serve accepts incoming HTTP connections on the wrapped listener l, creating a new
+// service goroutine for each. The service goroutines read requests and then call
+// handler to reply to them. Handler is typically nil, in which case the
+// DefaultServeMux is used.
+//
+// In addition to the standard Serve behaviour each connection is added to a
+// sync.Waitgroup so that all outstanding connections can be served before shutting
+// down the server.
+func (srv *Server) Serve(serve ServeFunction) error {
+ defer log.Debug("Serve() returning... (PID: %d)", syscall.Getpid())
+ srv.setState(stateRunning)
+ GetManager().RegisterServer()
+ err := serve(srv.listener)
+ log.Debug("Waiting for connections to finish... (PID: %d)", syscall.Getpid())
+ srv.wg.Wait()
+ srv.setState(stateTerminate)
+ GetManager().ServerDone()
+ // use of closed means that the listeners are closed - i.e. we should be shutting down - return nil
+ if err == nil || strings.Contains(err.Error(), "use of closed") || strings.Contains(err.Error(), "http: Server closed") {
+ return nil
+ }
+ return err
+}
+
+func (srv *Server) getState() state {
+ srv.lock.RLock()
+ defer srv.lock.RUnlock()
+
+ return srv.state
+}
+
+func (srv *Server) setState(st state) {
+ srv.lock.Lock()
+ defer srv.lock.Unlock()
+
+ srv.state = st
+}
+
+type filer interface {
+ File() (*os.File, error)
+}
+
+type wrappedListener struct {
+ net.Listener
+ stopped bool
+ server *Server
+}
+
+func newWrappedListener(l net.Listener, srv *Server) *wrappedListener {
+ return &wrappedListener{
+ Listener: l,
+ server: srv,
+ }
+}
+
+func (wl *wrappedListener) Accept() (net.Conn, error) {
+ var c net.Conn
+ // Set keepalive on TCPListeners connections.
+ if tcl, ok := wl.Listener.(*net.TCPListener); ok {
+ tc, err := tcl.AcceptTCP()
+ if err != nil {
+ return nil, err
+ }
+ _ = tc.SetKeepAlive(true) // see http.tcpKeepAliveListener
+ _ = tc.SetKeepAlivePeriod(3 * time.Minute) // see http.tcpKeepAliveListener
+ c = tc
+ } else {
+ var err error
+ c, err = wl.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ closed := int32(0)
+
+ c = &wrappedConn{
+ Conn: c,
+ server: wl.server,
+ closed: &closed,
+ perWriteTimeout: wl.server.PerWriteTimeout,
+ perWritePerKbTimeout: wl.server.PerWritePerKbTimeout,
+ }
+
+ wl.server.wg.Add(1)
+ return c, nil
+}
+
+func (wl *wrappedListener) Close() error {
+ if wl.stopped {
+ return syscall.EINVAL
+ }
+
+ wl.stopped = true
+ return wl.Listener.Close()
+}
+
+func (wl *wrappedListener) File() (*os.File, error) {
+ // returns a dup(2) - FD_CLOEXEC flag *not* set so the listening socket can be passed to child processes
+ return wl.Listener.(filer).File()
+}
+
+type wrappedConn struct {
+ net.Conn
+ server *Server
+ closed *int32
+ deadline time.Time
+ perWriteTimeout time.Duration
+ perWritePerKbTimeout time.Duration
+}
+
+func (w *wrappedConn) Write(p []byte) (n int, err error) {
+ if w.perWriteTimeout > 0 {
+ minTimeout := time.Duration(len(p)/1024) * w.perWritePerKbTimeout
+ minDeadline := time.Now().Add(minTimeout).Add(w.perWriteTimeout)
+
+ w.deadline = w.deadline.Add(minTimeout)
+ if minDeadline.After(w.deadline) {
+ w.deadline = minDeadline
+ }
+ _ = w.Conn.SetWriteDeadline(w.deadline)
+ }
+ return w.Conn.Write(p)
+}
+
+func (w *wrappedConn) Close() error {
+ if atomic.CompareAndSwapInt32(w.closed, 0, 1) {
+ defer func() {
+ if err := recover(); err != nil {
+ select {
+ case <-GetManager().IsHammer():
+ // Likely deadlocked request released at hammertime
+ log.Warn("Panic during connection close! %v. Likely there has been a deadlocked request which has been released by forced shutdown.", err)
+ default:
+ log.Error("Panic during connection close! %v", err)
+ }
+ }
+ }()
+ w.server.wg.Done()
+ }
+ return w.Conn.Close()
+}
diff --git a/modules/graceful/server_hooks.go b/modules/graceful/server_hooks.go
new file mode 100644
index 0000000..9b67589
--- /dev/null
+++ b/modules/graceful/server_hooks.go
@@ -0,0 +1,73 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package graceful
+
+import (
+ "os"
+ "runtime"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// awaitShutdown waits for the shutdown signal from the Manager
+func (srv *Server) awaitShutdown() {
+ select {
+ case <-GetManager().IsShutdown():
+ // Shutdown
+ srv.doShutdown()
+ case <-GetManager().IsHammer():
+ // Hammer
+ srv.doShutdown()
+ srv.doHammer()
+ }
+ <-GetManager().IsHammer()
+ srv.doHammer()
+}
+
+// shutdown closes the listener so that no new connections are accepted
+// and starts a goroutine that will hammer (stop all running requests) the server
+// after setting.GracefulHammerTime.
+func (srv *Server) doShutdown() {
+ // only shutdown if we're running.
+ if srv.getState() != stateRunning {
+ return
+ }
+
+ srv.setState(stateShuttingDown)
+
+ if srv.OnShutdown != nil {
+ srv.OnShutdown()
+ }
+ err := srv.listener.Close()
+ if err != nil {
+ log.Error("PID: %d Listener.Close() error: %v", os.Getpid(), err)
+ } else {
+ log.Info("PID: %d Listener (%s) closed.", os.Getpid(), srv.listener.Addr())
+ }
+}
+
+func (srv *Server) doHammer() {
+ defer func() {
+ // We call srv.wg.Done() until it panics.
+ // This happens if we call Done() when the WaitGroup counter is already at 0
+ // So if it panics -> we're done, Serve() will return and the
+ // parent will goroutine will exit.
+ if r := recover(); r != nil {
+ log.Error("WaitGroup at 0: Error: %v", r)
+ }
+ }()
+ if srv.getState() != stateShuttingDown {
+ return
+ }
+ log.Warn("Forcefully shutting down parent")
+ for {
+ if srv.getState() == stateTerminate {
+ break
+ }
+ srv.wg.Done()
+
+ // Give other goroutines a chance to finish before we forcibly stop them.
+ runtime.Gosched()
+ }
+}
diff --git a/modules/graceful/server_http.go b/modules/graceful/server_http.go
new file mode 100644
index 0000000..7c855ac
--- /dev/null
+++ b/modules/graceful/server_http.go
@@ -0,0 +1,37 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package graceful
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "net/http"
+)
+
+func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) {
+ server := NewServer(network, address, name)
+ httpServer := http.Server{
+ Handler: handler,
+ BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() },
+ }
+ server.OnShutdown = func() {
+ httpServer.SetKeepAlivesEnabled(false)
+ }
+ return server, httpServer.Serve
+}
+
+// HTTPListenAndServe listens on the provided network address and then calls Serve
+// to handle requests on incoming connections.
+func HTTPListenAndServe(network, address, name string, handler http.Handler, useProxyProtocol bool) error {
+ server, lHandler := newHTTPServer(network, address, name, handler)
+ return server.ListenAndServe(lHandler, useProxyProtocol)
+}
+
+// HTTPListenAndServeTLSConfig listens on the provided network address and then calls Serve
+// to handle requests on incoming connections.
+func HTTPListenAndServeTLSConfig(network, address, name string, tlsConfig *tls.Config, handler http.Handler, useProxyProtocol, proxyProtocolTLSBridging bool) error {
+ server, lHandler := newHTTPServer(network, address, name, handler)
+ return server.ListenAndServeTLSConfig(tlsConfig, lHandler, useProxyProtocol, proxyProtocolTLSBridging)
+}
diff --git a/modules/hcaptcha/error.go b/modules/hcaptcha/error.go
new file mode 100644
index 0000000..7b68bf8
--- /dev/null
+++ b/modules/hcaptcha/error.go
@@ -0,0 +1,47 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hcaptcha
+
+const (
+ ErrMissingInputSecret ErrorCode = "missing-input-secret"
+ ErrInvalidInputSecret ErrorCode = "invalid-input-secret"
+ ErrMissingInputResponse ErrorCode = "missing-input-response"
+ ErrInvalidInputResponse ErrorCode = "invalid-input-response"
+ ErrBadRequest ErrorCode = "bad-request"
+ ErrInvalidOrAlreadySeenResponse ErrorCode = "invalid-or-already-seen-response"
+ ErrNotUsingDummyPasscode ErrorCode = "not-using-dummy-passcode"
+ ErrSitekeySecretMismatch ErrorCode = "sitekey-secret-mismatch"
+)
+
+// ErrorCode is any possible error from hCaptcha
+type ErrorCode string
+
+// String fulfills the Stringer interface
+func (err ErrorCode) String() string {
+ switch err {
+ case ErrMissingInputSecret:
+ return "Your secret key is missing."
+ case ErrInvalidInputSecret:
+ return "Your secret key is invalid or malformed."
+ case ErrMissingInputResponse:
+ return "The response parameter (verification token) is missing."
+ case ErrInvalidInputResponse:
+ return "The response parameter (verification token) is invalid or malformed."
+ case ErrBadRequest:
+ return "The request is invalid or malformed."
+ case ErrInvalidOrAlreadySeenResponse:
+ return "The response parameter has already been checked, or has another issue."
+ case ErrNotUsingDummyPasscode:
+ return "You have used a testing sitekey but have not used its matching secret."
+ case ErrSitekeySecretMismatch:
+ return "The sitekey is not registered with the provided secret."
+ default:
+ return ""
+ }
+}
+
+// Error fulfills the error interface
+func (err ErrorCode) Error() string {
+ return err.String()
+}
diff --git a/modules/hcaptcha/hcaptcha.go b/modules/hcaptcha/hcaptcha.go
new file mode 100644
index 0000000..b970d49
--- /dev/null
+++ b/modules/hcaptcha/hcaptcha.go
@@ -0,0 +1,140 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hcaptcha
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+const verifyURL = "https://hcaptcha.com/siteverify"
+
+// Client is an hCaptcha client
+type Client struct {
+ ctx context.Context
+ http *http.Client
+
+ secret string
+}
+
+// PostOptions are optional post form values
+type PostOptions struct {
+ RemoteIP string
+ Sitekey string
+}
+
+// ClientOption is a func to modify a new Client
+type ClientOption func(*Client)
+
+// WithHTTP sets the http.Client of a Client
+func WithHTTP(httpClient *http.Client) func(*Client) {
+ return func(hClient *Client) {
+ hClient.http = httpClient
+ }
+}
+
+// WithContext sets the context.Context of a Client
+func WithContext(ctx context.Context) func(*Client) {
+ return func(hClient *Client) {
+ hClient.ctx = ctx
+ }
+}
+
+// New returns a new hCaptcha Client
+func New(secret string, options ...ClientOption) (*Client, error) {
+ if strings.TrimSpace(secret) == "" {
+ return nil, ErrMissingInputSecret
+ }
+
+ client := &Client{
+ ctx: context.Background(),
+ http: http.DefaultClient,
+ secret: secret,
+ }
+
+ for _, opt := range options {
+ opt(client)
+ }
+
+ return client, nil
+}
+
+// Response is an hCaptcha response
+type Response struct {
+ Success bool `json:"success"`
+ ChallengeTS string `json:"challenge_ts"`
+ Hostname string `json:"hostname"`
+ Credit bool `json:"credit,omitempty"`
+ ErrorCodes []ErrorCode `json:"error-codes"`
+}
+
+// Verify checks the response against the hCaptcha API
+func (c *Client) Verify(token string, opts PostOptions) (*Response, error) {
+ if strings.TrimSpace(token) == "" {
+ return nil, ErrMissingInputResponse
+ }
+
+ post := url.Values{
+ "secret": []string{c.secret},
+ "response": []string{token},
+ }
+ if strings.TrimSpace(opts.RemoteIP) != "" {
+ post.Add("remoteip", opts.RemoteIP)
+ }
+ if strings.TrimSpace(opts.Sitekey) != "" {
+ post.Add("sitekey", opts.Sitekey)
+ }
+
+ // Basically a copy of http.PostForm, but with a context
+ req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, verifyURL, strings.NewReader(post.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var response *Response
+ if err := json.Unmarshal(body, &response); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+// Verify calls hCaptcha API to verify token
+func Verify(ctx context.Context, response string) (bool, error) {
+ client, err := New(setting.Service.HcaptchaSecret, WithContext(ctx))
+ if err != nil {
+ return false, err
+ }
+
+ resp, err := client.Verify(response, PostOptions{
+ Sitekey: setting.Service.HcaptchaSitekey,
+ })
+ if err != nil {
+ return false, err
+ }
+
+ var respErr error
+ if len(resp.ErrorCodes) > 0 {
+ respErr = resp.ErrorCodes[0]
+ }
+ return resp.Success, respErr
+}
diff --git a/modules/hcaptcha/hcaptcha_test.go b/modules/hcaptcha/hcaptcha_test.go
new file mode 100644
index 0000000..55e01ec
--- /dev/null
+++ b/modules/hcaptcha/hcaptcha_test.go
@@ -0,0 +1,106 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hcaptcha
+
+import (
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+const (
+ dummySiteKey = "10000000-ffff-ffff-ffff-000000000001"
+ dummySecret = "0x0000000000000000000000000000000000000000"
+ dummyToken = "10000000-aaaa-bbbb-cccc-000000000001"
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(m.Run())
+}
+
+func TestCaptcha(t *testing.T) {
+ tt := []struct {
+ Name string
+ Secret string
+ Token string
+ Error ErrorCode
+ }{
+ {
+ Name: "Success",
+ Secret: dummySecret,
+ Token: dummyToken,
+ },
+ {
+ Name: "Missing Secret",
+ Token: dummyToken,
+ Error: ErrMissingInputSecret,
+ },
+ {
+ Name: "Missing Token",
+ Secret: dummySecret,
+ Error: ErrMissingInputResponse,
+ },
+ {
+ Name: "Invalid Token",
+ Secret: dummySecret,
+ Token: "test",
+ Error: ErrInvalidInputResponse,
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.Name, func(t *testing.T) {
+ client, err := New(tc.Secret, WithHTTP(&http.Client{
+ Timeout: time.Second * 5,
+ }))
+ if err != nil {
+ // The only error that can be returned from creating a client
+ if tc.Error == ErrMissingInputSecret && err == ErrMissingInputSecret {
+ return
+ }
+ t.Log(err)
+ t.FailNow()
+ }
+
+ resp, err := client.Verify(tc.Token, PostOptions{
+ Sitekey: dummySiteKey,
+ })
+ if err != nil {
+ // The only error that can be returned prior to the request
+ if tc.Error == ErrMissingInputResponse && err == ErrMissingInputResponse {
+ return
+ }
+ t.Log(err)
+ t.FailNow()
+ }
+
+ if tc.Error.String() != "" {
+ if resp.Success {
+ t.Log("Verification should fail.")
+ t.Fail()
+ }
+ if len(resp.ErrorCodes) == 0 {
+ t.Log("hCaptcha should have returned an error.")
+ t.Fail()
+ }
+ var hasErr bool
+ for _, err := range resp.ErrorCodes {
+ if strings.EqualFold(err.String(), tc.Error.String()) {
+ hasErr = true
+ break
+ }
+ }
+ if !hasErr {
+ t.Log("hCaptcha did not return the error being tested")
+ t.Fail()
+ }
+ } else if !resp.Success {
+ t.Log("Verification should succeed.")
+ t.Fail()
+ }
+ })
+ }
+}
diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go
new file mode 100644
index 0000000..bd6137d
--- /dev/null
+++ b/modules/highlight/highlight.go
@@ -0,0 +1,224 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package highlight
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ gohtml "html"
+ "html/template"
+ "io"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/analyze"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/alecthomas/chroma/v2/styles"
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+// don't index files larger than this many bytes for performance purposes
+const sizeLimit = 1024 * 1024
+
+var (
+ // For custom user mapping
+ highlightMapping = map[string]string{}
+
+ once sync.Once
+
+ cache *lru.TwoQueueCache[string, any]
+
+ githubStyles = styles.Get("github")
+)
+
+// NewContext loads custom highlight map from local config
+func NewContext() {
+ once.Do(func() {
+ highlightMapping = setting.GetHighlightMapping()
+
+ // The size 512 is simply a conservative rule of thumb
+ c, err := lru.New2Q[string, any](512)
+ if err != nil {
+ panic(fmt.Sprintf("failed to initialize LRU cache for highlighter: %s", err))
+ }
+ cache = c
+ })
+}
+
+// Code returns a HTML version of code string with chroma syntax highlighting classes and the matched lexer name
+func Code(fileName, language, code string) (output template.HTML, lexerName string) {
+ NewContext()
+
+ // diff view newline will be passed as empty, change to literal '\n' so it can be copied
+ // preserve literal newline in blame view
+ if code == "" || code == "\n" {
+ return "\n", ""
+ }
+
+ if len(code) > sizeLimit {
+ return template.HTML(template.HTMLEscapeString(code)), ""
+ }
+
+ var lexer chroma.Lexer
+
+ if len(language) > 0 {
+ lexer = lexers.Get(language)
+
+ if lexer == nil {
+ // Attempt stripping off the '?'
+ if idx := strings.IndexByte(language, '?'); idx > 0 {
+ lexer = lexers.Get(language[:idx])
+ }
+ }
+ }
+
+ if lexer == nil {
+ if val, ok := highlightMapping[filepath.Ext(fileName)]; ok {
+ // use mapped value to find lexer
+ lexer = lexers.Get(val)
+ }
+ }
+
+ if lexer == nil {
+ if l, ok := cache.Get(fileName); ok {
+ lexer = l.(chroma.Lexer)
+ }
+ }
+
+ if lexer == nil {
+ lexer = lexers.Match(strings.ToLower(fileName))
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+ cache.Add(fileName, lexer)
+ }
+
+ return CodeFromLexer(lexer, code), formatLexerName(lexer.Config().Name)
+}
+
+// CodeFromLexer returns a HTML version of code string with chroma syntax highlighting classes
+func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML {
+ formatter := html.New(html.WithClasses(true),
+ html.WithLineNumbers(false),
+ html.PreventSurroundingPre(true),
+ )
+
+ htmlbuf := bytes.Buffer{}
+ htmlw := bufio.NewWriter(&htmlbuf)
+
+ iterator, err := lexer.Tokenise(nil, code)
+ if err != nil {
+ log.Error("Can't tokenize code: %v", err)
+ return template.HTML(template.HTMLEscapeString(code))
+ }
+ // style not used for live site but need to pass something
+ err = formatter.Format(htmlw, githubStyles, iterator)
+ if err != nil {
+ log.Error("Can't format code: %v", err)
+ return template.HTML(template.HTMLEscapeString(code))
+ }
+
+ _ = htmlw.Flush()
+ // Chroma will add newlines for certain lexers in order to highlight them properly
+ // Once highlighted, strip them here, so they don't cause copy/paste trouble in HTML output
+ return template.HTML(strings.TrimSuffix(htmlbuf.String(), "\n"))
+}
+
+// File returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name
+func File(fileName, language string, code []byte) ([]template.HTML, string, error) {
+ NewContext()
+
+ if len(code) > sizeLimit {
+ return PlainText(code), "", nil
+ }
+
+ formatter := html.New(html.WithClasses(true),
+ html.WithLineNumbers(false),
+ html.PreventSurroundingPre(true),
+ )
+
+ var lexer chroma.Lexer
+
+ // provided language overrides everything
+ if language != "" {
+ lexer = lexers.Get(language)
+ }
+
+ if lexer == nil {
+ if val, ok := highlightMapping[filepath.Ext(fileName)]; ok {
+ lexer = lexers.Get(val)
+ }
+ }
+
+ if lexer == nil {
+ guessLanguage := analyze.GetCodeLanguage(fileName, code)
+
+ lexer = lexers.Get(guessLanguage)
+ if lexer == nil {
+ lexer = lexers.Match(strings.ToLower(fileName))
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+ }
+ }
+
+ lexerName := formatLexerName(lexer.Config().Name)
+
+ iterator, err := lexer.Tokenise(nil, string(code))
+ if err != nil {
+ return nil, "", fmt.Errorf("can't tokenize code: %w", err)
+ }
+
+ tokensLines := chroma.SplitTokensIntoLines(iterator.Tokens())
+ htmlBuf := &bytes.Buffer{}
+
+ lines := make([]template.HTML, 0, len(tokensLines))
+ for _, tokens := range tokensLines {
+ iterator = chroma.Literator(tokens...)
+ err = formatter.Format(htmlBuf, githubStyles, iterator)
+ if err != nil {
+ return nil, "", fmt.Errorf("can't format code: %w", err)
+ }
+ lines = append(lines, template.HTML(htmlBuf.String()))
+ htmlBuf.Reset()
+ }
+
+ return lines, lexerName, nil
+}
+
+// PlainText returns non-highlighted HTML for code
+func PlainText(code []byte) []template.HTML {
+ r := bufio.NewReader(bytes.NewReader(code))
+ m := make([]template.HTML, 0, bytes.Count(code, []byte{'\n'})+1)
+ for {
+ content, err := r.ReadString('\n')
+ if err != nil && err != io.EOF {
+ log.Error("failed to read string from buffer: %v", err)
+ break
+ }
+ if content == "" && err == io.EOF {
+ break
+ }
+ s := template.HTML(gohtml.EscapeString(content))
+ m = append(m, s)
+ }
+ return m
+}
+
+func formatLexerName(name string) string {
+ if name == "fallback" || name == "plaintext" {
+ return "Text"
+ }
+
+ return util.ToTitleCaseNoLower(name)
+}
diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go
new file mode 100644
index 0000000..03db4d5
--- /dev/null
+++ b/modules/highlight/highlight_test.go
@@ -0,0 +1,190 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package highlight
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func lines(s string) (out []template.HTML) {
+ // "" => [], "a" => ["a"], "a\n" => ["a\n"], "a\nb" => ["a\n", "b"] (each line always includes EOL "\n" if it exists)
+ out = make([]template.HTML, 0)
+ s = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(s), "\n", ""), `\n`, "\n")
+ for {
+ if p := strings.IndexByte(s, '\n'); p != -1 {
+ out = append(out, template.HTML(s[:p+1]))
+ s = s[p+1:]
+ } else {
+ break
+ }
+ }
+ if s != "" {
+ out = append(out, template.HTML(s))
+ }
+ return out
+}
+
+func TestFile(t *testing.T) {
+ tests := []struct {
+ name string
+ code string
+ want []template.HTML
+ lexerName string
+ }{
+ {
+ name: "empty.py",
+ code: "",
+ want: lines(""),
+ lexerName: "Python",
+ },
+ {
+ name: "empty.js",
+ code: "",
+ want: lines(""),
+ lexerName: "JavaScript",
+ },
+ {
+ name: "empty.yaml",
+ code: "",
+ want: lines(""),
+ lexerName: "YAML",
+ },
+ {
+ name: "tags.txt",
+ code: "<>",
+ want: lines("&lt;&gt;"),
+ lexerName: "Text",
+ },
+ {
+ name: "tags.py",
+ code: "<>",
+ want: lines(`<span class="o">&lt;</span><span class="o">&gt;</span>`),
+ lexerName: "Python",
+ },
+ {
+ name: "eol-no.py",
+ code: "a=1",
+ want: lines(`<span class="n">a</span><span class="o">=</span><span class="mi">1</span>`),
+ lexerName: "Python",
+ },
+ {
+ name: "eol-newline1.py",
+ code: "a=1\n",
+ want: lines(`<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n`),
+ lexerName: "Python",
+ },
+ {
+ name: "eol-newline2.py",
+ code: "a=1\n\n",
+ want: lines(`
+<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
+\n
+ `,
+ ),
+ lexerName: "Python",
+ },
+ {
+ name: "empty-line-with-space.py",
+ code: strings.ReplaceAll(strings.TrimSpace(`
+def:
+ a=1
+
+b=''
+{space}
+c=2
+ `), "{space}", " "),
+ want: lines(`
+<span class="n">def</span><span class="p">:</span>\n
+ <span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
+\n
+<span class="n">b</span><span class="o">=</span><span class="sa"></span><span class="s1">&#39;</span><span class="s1">&#39;</span>\n
+ \n
+<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
+ ),
+ lexerName: "Python",
+ },
+ {
+ name: "DOS.PAS",
+ code: "",
+ want: lines(""),
+ lexerName: "ObjectPascal",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, lexerName, err := File(tt.name, "", []byte(tt.code))
+ require.NoError(t, err)
+ assert.EqualValues(t, tt.want, out)
+ assert.Equal(t, tt.lexerName, lexerName)
+ })
+ }
+}
+
+func TestPlainText(t *testing.T) {
+ tests := []struct {
+ name string
+ code string
+ want []template.HTML
+ }{
+ {
+ name: "empty.py",
+ code: "",
+ want: lines(""),
+ },
+ {
+ name: "tags.py",
+ code: "<>",
+ want: lines("&lt;&gt;"),
+ },
+ {
+ name: "eol-no.py",
+ code: "a=1",
+ want: lines(`a=1`),
+ },
+ {
+ name: "eol-newline1.py",
+ code: "a=1\n",
+ want: lines(`a=1\n`),
+ },
+ {
+ name: "eol-newline2.py",
+ code: "a=1\n\n",
+ want: lines(`
+a=1\n
+\n
+ `),
+ },
+ {
+ name: "empty-line-with-space.py",
+ code: strings.ReplaceAll(strings.TrimSpace(`
+def:
+ a=1
+
+b=''
+{space}
+c=2
+ `), "{space}", " "),
+ want: lines(`
+def:\n
+ a=1\n
+\n
+b=&#39;&#39;\n
+ \n
+c=2`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out := PlainText([]byte(tt.code))
+ assert.EqualValues(t, tt.want, out)
+ })
+ }
+}
diff --git a/modules/hostmatcher/hostmatcher.go b/modules/hostmatcher/hostmatcher.go
new file mode 100644
index 0000000..1069310
--- /dev/null
+++ b/modules/hostmatcher/hostmatcher.go
@@ -0,0 +1,161 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hostmatcher
+
+import (
+ "net"
+ "path/filepath"
+ "strings"
+)
+
+// HostMatchList is used to check if a host or IP is in a list.
+type HostMatchList struct {
+ SettingKeyHint string
+ SettingValue string
+
+ // builtins networks
+ builtins []string
+ // patterns for host names (with wildcard support)
+ patterns []string
+ // ipNets is the CIDR network list
+ ipNets []*net.IPNet
+}
+
+// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
+const MatchBuiltinExternal = "external"
+
+// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
+const MatchBuiltinPrivate = "private"
+
+// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
+const MatchBuiltinLoopback = "loopback"
+
+func isBuiltin(s string) bool {
+ return s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback
+}
+
+// ParseHostMatchList parses the host list HostMatchList
+func ParseHostMatchList(settingKeyHint, hostList string) *HostMatchList {
+ hl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList}
+ for _, s := range strings.Split(hostList, ",") {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if s == "" {
+ continue
+ }
+ _, ipNet, err := net.ParseCIDR(s)
+ if err == nil {
+ hl.ipNets = append(hl.ipNets, ipNet)
+ } else if isBuiltin(s) {
+ hl.builtins = append(hl.builtins, s)
+ } else {
+ hl.patterns = append(hl.patterns, s)
+ }
+ }
+ return hl
+}
+
+// ParseSimpleMatchList parse a simple matchlist (no built-in networks, no CIDR support, only wildcard pattern match)
+func ParseSimpleMatchList(settingKeyHint, matchList string) *HostMatchList {
+ hl := &HostMatchList{
+ SettingKeyHint: settingKeyHint,
+ SettingValue: matchList,
+ }
+ for _, s := range strings.Split(matchList, ",") {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if s == "" {
+ continue
+ }
+ // we keep the same result as old `matchlist`, so no builtin/CIDR support here, we only match wildcard patterns
+ hl.patterns = append(hl.patterns, s)
+ }
+ return hl
+}
+
+// AppendBuiltin appends more builtins to match
+func (hl *HostMatchList) AppendBuiltin(builtin string) {
+ hl.builtins = append(hl.builtins, builtin)
+}
+
+// AppendPattern appends more pattern to match
+func (hl *HostMatchList) AppendPattern(pattern string) {
+ hl.patterns = append(hl.patterns, pattern)
+}
+
+// IsEmpty checks if the checklist is empty
+func (hl *HostMatchList) IsEmpty() bool {
+ return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)
+}
+
+func (hl *HostMatchList) checkPattern(host string) bool {
+ host = strings.ToLower(strings.TrimSpace(host))
+ for _, pattern := range hl.patterns {
+ if matched, _ := filepath.Match(pattern, host); matched {
+ return true
+ }
+ }
+ return false
+}
+
+func (hl *HostMatchList) checkIP(ip net.IP) bool {
+ for _, pattern := range hl.patterns {
+ if pattern == "*" {
+ return true
+ }
+ }
+ for _, builtin := range hl.builtins {
+ switch builtin {
+ case MatchBuiltinExternal:
+ if ip.IsGlobalUnicast() && !ip.IsPrivate() {
+ return true
+ }
+ case MatchBuiltinPrivate:
+ if ip.IsPrivate() {
+ return true
+ }
+ case MatchBuiltinLoopback:
+ if ip.IsLoopback() {
+ return true
+ }
+ }
+ }
+ for _, ipNet := range hl.ipNets {
+ if ipNet.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// MatchHostName checks if the host matches an allow/deny(block) list
+func (hl *HostMatchList) MatchHostName(host string) bool {
+ if hl == nil {
+ return false
+ }
+
+ hostname, _, err := net.SplitHostPort(host)
+ if err != nil {
+ hostname = host
+ }
+ if hl.checkPattern(hostname) {
+ return true
+ }
+ if ip := net.ParseIP(hostname); ip != nil {
+ return hl.checkIP(ip)
+ }
+ return false
+}
+
+// MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`
+func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
+ if hl == nil {
+ return false
+ }
+ host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
+ return hl.checkPattern(host) || hl.checkIP(ip)
+}
+
+// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
+func (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool {
+ return hl.MatchHostName(host) || hl.MatchIPAddr(ip)
+}
diff --git a/modules/hostmatcher/hostmatcher_test.go b/modules/hostmatcher/hostmatcher_test.go
new file mode 100644
index 0000000..c781847
--- /dev/null
+++ b/modules/hostmatcher/hostmatcher_test.go
@@ -0,0 +1,161 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hostmatcher
+
+import (
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHostOrIPMatchesList(t *testing.T) {
+ type tc struct {
+ host string
+ ip net.IP
+ expected bool
+ }
+
+ // for IPv6: "::1" is loopback, "fd00::/8" is private
+
+ hl := ParseHostMatchList("", "private, External, *.myDomain.com, 169.254.1.0/24")
+
+ test := func(cases []tc) {
+ for _, c := range cases {
+ assert.Equalf(t, c.expected, hl.MatchHostOrIP(c.host, c.ip), "case domain=%s, ip=%v, expected=%v", c.host, c.ip, c.expected)
+ }
+ }
+
+ cases := []tc{
+ {"", net.IPv4zero, false},
+ {"", net.IPv6zero, false},
+
+ {"", net.ParseIP("127.0.0.1"), false},
+ {"127.0.0.1", nil, false},
+ {"", net.ParseIP("::1"), false},
+
+ {"", net.ParseIP("10.0.1.1"), true},
+ {"10.0.1.1", nil, true},
+ {"10.0.1.1:8080", nil, true},
+ {"", net.ParseIP("192.168.1.1"), true},
+ {"192.168.1.1", nil, true},
+ {"", net.ParseIP("fd00::1"), true},
+ {"fd00::1", nil, true},
+
+ {"", net.ParseIP("8.8.8.8"), true},
+ {"", net.ParseIP("1001::1"), true},
+
+ {"mydomain.com", net.IPv4zero, false},
+ {"sub.mydomain.com", net.IPv4zero, true},
+ {"sub.mydomain.com:8080", net.IPv4zero, true},
+
+ {"", net.ParseIP("169.254.1.1"), true},
+ {"169.254.1.1", nil, true},
+ {"", net.ParseIP("169.254.2.2"), false},
+ {"169.254.2.2", nil, false},
+ }
+ test(cases)
+
+ hl = ParseHostMatchList("", "loopback")
+ cases = []tc{
+ {"", net.IPv4zero, false},
+ {"", net.ParseIP("127.0.0.1"), true},
+ {"", net.ParseIP("10.0.1.1"), false},
+ {"", net.ParseIP("192.168.1.1"), false},
+ {"", net.ParseIP("8.8.8.8"), false},
+
+ {"", net.ParseIP("::1"), true},
+ {"", net.ParseIP("fd00::1"), false},
+ {"", net.ParseIP("1000::1"), false},
+
+ {"mydomain.com", net.IPv4zero, false},
+ }
+ test(cases)
+
+ hl = ParseHostMatchList("", "private")
+ cases = []tc{
+ {"", net.IPv4zero, false},
+ {"", net.ParseIP("127.0.0.1"), false},
+ {"", net.ParseIP("10.0.1.1"), true},
+ {"", net.ParseIP("192.168.1.1"), true},
+ {"", net.ParseIP("8.8.8.8"), false},
+
+ {"", net.ParseIP("::1"), false},
+ {"", net.ParseIP("fd00::1"), true},
+ {"", net.ParseIP("1000::1"), false},
+
+ {"mydomain.com", net.IPv4zero, false},
+ }
+ test(cases)
+
+ hl = ParseHostMatchList("", "external")
+ cases = []tc{
+ {"", net.IPv4zero, false},
+ {"", net.ParseIP("127.0.0.1"), false},
+ {"", net.ParseIP("10.0.1.1"), false},
+ {"", net.ParseIP("192.168.1.1"), false},
+ {"", net.ParseIP("8.8.8.8"), true},
+
+ {"", net.ParseIP("::1"), false},
+ {"", net.ParseIP("fd00::1"), false},
+ {"", net.ParseIP("1000::1"), true},
+
+ {"mydomain.com", net.IPv4zero, false},
+ }
+ test(cases)
+
+ hl = ParseHostMatchList("", "*")
+ cases = []tc{
+ {"", net.IPv4zero, true},
+ {"", net.ParseIP("127.0.0.1"), true},
+ {"", net.ParseIP("10.0.1.1"), true},
+ {"", net.ParseIP("192.168.1.1"), true},
+ {"", net.ParseIP("8.8.8.8"), true},
+
+ {"", net.ParseIP("::1"), true},
+ {"", net.ParseIP("fd00::1"), true},
+ {"", net.ParseIP("1000::1"), true},
+
+ {"mydomain.com", net.IPv4zero, true},
+ }
+ test(cases)
+
+ // built-in network names can be escaped (warping the first char with `[]`) to be used as a real host name
+ // this mechanism is reversed for internal usage only (maybe for some rare cases), it's not supposed to be used by end users
+ // a real user should never use loopback/private/external as their host names
+ hl = ParseHostMatchList("", "loopback, [p]rivate")
+ cases = []tc{
+ {"loopback", nil, false},
+ {"", net.ParseIP("127.0.0.1"), true},
+ {"private", nil, true},
+ {"", net.ParseIP("192.168.1.1"), false},
+ }
+ test(cases)
+
+ hl = ParseSimpleMatchList("", "loopback, *.domain.com")
+ cases = []tc{
+ {"loopback", nil, true},
+ {"", net.ParseIP("127.0.0.1"), false},
+ {"sub.domain.com", nil, true},
+ {"other.com", nil, false},
+ {"", net.ParseIP("1.1.1.1"), false},
+ }
+ test(cases)
+
+ hl = ParseSimpleMatchList("", "external")
+ cases = []tc{
+ {"", net.ParseIP("192.168.1.1"), false},
+ {"", net.ParseIP("1.1.1.1"), false},
+ {"external", nil, true},
+ }
+ test(cases)
+
+ hl = ParseSimpleMatchList("", "")
+ cases = []tc{
+ {"", net.ParseIP("192.168.1.1"), false},
+ {"", net.ParseIP("1.1.1.1"), false},
+ {"external", nil, false},
+ }
+ test(cases)
+}
diff --git a/modules/hostmatcher/http.go b/modules/hostmatcher/http.go
new file mode 100644
index 0000000..8828902
--- /dev/null
+++ b/modules/hostmatcher/http.go
@@ -0,0 +1,65 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package hostmatcher
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/url"
+ "syscall"
+ "time"
+)
+
+// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check
+func NewDialContext(usage string, allowList, blockList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ // How Go HTTP Client works with redirection:
+ // transport.RoundTrip URL=http://domain.com, Host=domain.com
+ // transport.DialContext addrOrHost=domain.com:80
+ // dialer.Control tcp4:11.22.33.44:80
+ // transport.RoundTrip URL=http://www.domain.com/, Host=(empty here, in the direction, HTTP client doesn't fill the Host field)
+ // transport.DialContext addrOrHost=domain.com:80
+ // dialer.Control tcp4:11.22.33.44:80
+ return func(ctx context.Context, network, addrOrHost string) (net.Conn, error) {
+ dialer := net.Dialer{
+ // default values comes from http.DefaultTransport
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+
+ Control: func(network, ipAddr string, c syscall.RawConn) error {
+ host, port, err := net.SplitHostPort(addrOrHost)
+ if err != nil {
+ return err
+ }
+ if proxy != nil {
+ // Always allow the host of the proxy, but only on the specified port.
+ if host == proxy.Hostname() && port == proxy.Port() {
+ return nil
+ }
+ }
+
+ // in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
+ tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
+ if err != nil {
+ return fmt.Errorf("%s can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%w", usage, host, network, ipAddr, err)
+ }
+
+ var blockedError error
+ if blockList.MatchHostOrIP(host, tcpAddr.IP) {
+ blockedError = fmt.Errorf("%s can not call blocked HTTP servers (check your %s setting), deny '%s(%s)'", usage, blockList.SettingKeyHint, host, ipAddr)
+ }
+
+ // if we have an allow-list, check the allow-list first
+ if !allowList.IsEmpty() {
+ if !allowList.MatchHostOrIP(host, tcpAddr.IP) {
+ return fmt.Errorf("%s can only call allowed HTTP servers (check your %s setting), deny '%s(%s)'", usage, allowList.SettingKeyHint, host, ipAddr)
+ }
+ }
+ // otherwise, we always follow the blocked list
+ return blockedError
+ },
+ }
+ return dialer.DialContext(ctx, network, addrOrHost)
+ }
+}
diff --git a/modules/html/html.go b/modules/html/html.go
new file mode 100644
index 0000000..b1ebd58
--- /dev/null
+++ b/modules/html/html.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package html
+
+// ParseSizeAndClass get size and class from string with default values
+// If present, "others" expects the new size first and then the classes to use
+func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int, string) {
+ size := defaultSize
+ if len(others) >= 1 {
+ if v, ok := others[0].(int); ok && v != 0 {
+ size = v
+ }
+ }
+ class := defaultClass
+ if len(others) >= 2 {
+ if v, ok := others[1].(string); ok && v != "" {
+ if class != "" {
+ class += " "
+ }
+ class += v
+ }
+ }
+ return size, class
+}
diff --git a/modules/httpcache/httpcache.go b/modules/httpcache/httpcache.go
new file mode 100644
index 0000000..30ce0a4
--- /dev/null
+++ b/modules/httpcache/httpcache.go
@@ -0,0 +1,101 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httpcache
+
+import (
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// SetCacheControlInHeader sets suitable cache-control headers in the response
+func SetCacheControlInHeader(h http.Header, maxAge time.Duration, additionalDirectives ...string) {
+ directives := make([]string, 0, 2+len(additionalDirectives))
+
+ // "max-age=0 + must-revalidate" (aka "no-cache") is preferred instead of "no-store"
+ // because browsers may restore some input fields after navigate-back / reload a page.
+ if setting.IsProd {
+ if maxAge == 0 {
+ directives = append(directives, "max-age=0", "private", "must-revalidate")
+ } else {
+ directives = append(directives, "private", "max-age="+strconv.Itoa(int(maxAge.Seconds())))
+ }
+ } else {
+ directives = append(directives, "max-age=0", "private", "must-revalidate")
+
+ // to remind users they are using non-prod setting.
+ h.Set("X-Gitea-Debug", "RUN_MODE="+setting.RunMode)
+ h.Set("X-Forgejo-Debug", "RUN_MODE="+setting.RunMode)
+ }
+
+ h.Set("Cache-Control", strings.Join(append(directives, additionalDirectives...), ", "))
+}
+
+func ServeContentWithCacheControl(w http.ResponseWriter, req *http.Request, name string, modTime time.Time, content io.ReadSeeker) {
+ SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
+ http.ServeContent(w, req, name, modTime, content)
+}
+
+// HandleGenericETagCache handles ETag-based caching for a HTTP request.
+// It returns true if the request was handled.
+func HandleGenericETagCache(req *http.Request, w http.ResponseWriter, etag string) (handled bool) {
+ if len(etag) > 0 {
+ w.Header().Set("Etag", etag)
+ if checkIfNoneMatchIsValid(req, etag) {
+ w.WriteHeader(http.StatusNotModified)
+ return true
+ }
+ }
+ SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
+ return false
+}
+
+// checkIfNoneMatchIsValid tests if the header If-None-Match matches the ETag
+func checkIfNoneMatchIsValid(req *http.Request, etag string) bool {
+ ifNoneMatch := req.Header.Get("If-None-Match")
+ if len(ifNoneMatch) > 0 {
+ for _, item := range strings.Split(ifNoneMatch, ",") {
+ item = strings.TrimPrefix(strings.TrimSpace(item), "W/") // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#directives
+ if item == etag {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// HandleGenericETagTimeCache handles ETag-based caching with Last-Modified caching for a HTTP request.
+// It returns true if the request was handled.
+func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) (handled bool) {
+ if len(etag) > 0 {
+ w.Header().Set("Etag", etag)
+ }
+ if lastModified != nil && !lastModified.IsZero() {
+ // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
+ w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat))
+ }
+
+ if len(etag) > 0 {
+ if checkIfNoneMatchIsValid(req, etag) {
+ w.WriteHeader(http.StatusNotModified)
+ return true
+ }
+ }
+ if lastModified != nil && !lastModified.IsZero() {
+ ifModifiedSince := req.Header.Get("If-Modified-Since")
+ if ifModifiedSince != "" {
+ t, err := time.Parse(http.TimeFormat, ifModifiedSince)
+ if err == nil && lastModified.Unix() <= t.Unix() {
+ w.WriteHeader(http.StatusNotModified)
+ return true
+ }
+ }
+ }
+ SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
+ return false
+}
diff --git a/modules/httpcache/httpcache_test.go b/modules/httpcache/httpcache_test.go
new file mode 100644
index 0000000..65a8a9b
--- /dev/null
+++ b/modules/httpcache/httpcache_test.go
@@ -0,0 +1,100 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httpcache
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func countFormalHeaders(h http.Header) (c int) {
+ for k := range h {
+ // ignore our headers for internal usage
+ if strings.HasPrefix(k, "X-Gitea-") {
+ continue
+ }
+ if strings.HasPrefix(k, "X-Forgejo-") {
+ continue
+ }
+ c++
+ }
+ return c
+}
+
+func TestHandleGenericETagCache(t *testing.T) {
+ etag := `"test"`
+
+ t.Run("No_If-None-Match", func(t *testing.T) {
+ req := &http.Request{Header: make(http.Header)}
+ w := httptest.NewRecorder()
+
+ handled := HandleGenericETagCache(req, w, etag)
+
+ assert.False(t, handled)
+ assert.Equal(t, 2, countFormalHeaders(w.Header()))
+ assert.Contains(t, w.Header(), "Cache-Control")
+ assert.Contains(t, w.Header(), "Etag")
+ assert.Equal(t, etag, w.Header().Get("Etag"))
+ })
+ t.Run("Wrong_If-None-Match", func(t *testing.T) {
+ req := &http.Request{Header: make(http.Header)}
+ w := httptest.NewRecorder()
+
+ req.Header.Set("If-None-Match", `"wrong etag"`)
+
+ handled := HandleGenericETagCache(req, w, etag)
+
+ assert.False(t, handled)
+ assert.Equal(t, 2, countFormalHeaders(w.Header()))
+ assert.Contains(t, w.Header(), "Cache-Control")
+ assert.Contains(t, w.Header(), "Etag")
+ assert.Equal(t, etag, w.Header().Get("Etag"))
+ })
+ t.Run("Correct_If-None-Match", func(t *testing.T) {
+ req := &http.Request{Header: make(http.Header)}
+ w := httptest.NewRecorder()
+
+ req.Header.Set("If-None-Match", etag)
+
+ handled := HandleGenericETagCache(req, w, etag)
+
+ assert.True(t, handled)
+ assert.Equal(t, 1, countFormalHeaders(w.Header()))
+ assert.Contains(t, w.Header(), "Etag")
+ assert.Equal(t, etag, w.Header().Get("Etag"))
+ assert.Equal(t, http.StatusNotModified, w.Code)
+ })
+ t.Run("Multiple_Wrong_If-None-Match", func(t *testing.T) {
+ req := &http.Request{Header: make(http.Header)}
+ w := httptest.NewRecorder()
+
+ req.Header.Set("If-None-Match", `"wrong etag", "wrong etag "`)
+
+ handled := HandleGenericETagCache(req, w, etag)
+
+ assert.False(t, handled)
+ assert.Equal(t, 2, countFormalHeaders(w.Header()))
+ assert.Contains(t, w.Header(), "Cache-Control")
+ assert.Contains(t, w.Header(), "Etag")
+ assert.Equal(t, etag, w.Header().Get("Etag"))
+ })
+ t.Run("Multiple_Correct_If-None-Match", func(t *testing.T) {
+ req := &http.Request{Header: make(http.Header)}
+ w := httptest.NewRecorder()
+
+ req.Header.Set("If-None-Match", `"wrong etag", `+etag)
+
+ handled := HandleGenericETagCache(req, w, etag)
+
+ assert.True(t, handled)
+ assert.Equal(t, 1, countFormalHeaders(w.Header()))
+ assert.Contains(t, w.Header(), "Etag")
+ assert.Equal(t, etag, w.Header().Get("Etag"))
+ assert.Equal(t, http.StatusNotModified, w.Code)
+ })
+}
diff --git a/modules/httplib/request.go b/modules/httplib/request.go
new file mode 100644
index 0000000..880d7ad
--- /dev/null
+++ b/modules/httplib/request.go
@@ -0,0 +1,206 @@
+// Copyright 2013 The Beego Authors. All rights reserved.
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httplib
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+var defaultSetting = Settings{"GiteaServer", 60 * time.Second, 60 * time.Second, nil, nil}
+
+// newRequest returns *Request with specific method
+func newRequest(url, method string) *Request {
+ var resp http.Response
+ req := http.Request{
+ Method: method,
+ Header: make(http.Header),
+ Proto: "HTTP/1.1",
+ ProtoMajor: 1,
+ ProtoMinor: 1,
+ }
+ return &Request{url, &req, map[string]string{}, defaultSetting, &resp, nil}
+}
+
+// NewRequest returns *Request with specific method
+func NewRequest(url, method string) *Request {
+ return newRequest(url, method)
+}
+
+// Settings is the default settings for http client
+type Settings struct {
+ UserAgent string
+ ConnectTimeout time.Duration
+ ReadWriteTimeout time.Duration
+ TLSClientConfig *tls.Config
+ Transport http.RoundTripper
+}
+
+// Request provides more useful methods for requesting one url than http.Request.
+type Request struct {
+ url string
+ req *http.Request
+ params map[string]string
+ setting Settings
+ resp *http.Response
+ body []byte
+}
+
+// SetContext sets the request's Context
+func (r *Request) SetContext(ctx context.Context) *Request {
+ r.req = r.req.WithContext(ctx)
+ return r
+}
+
+// SetTimeout sets connect time out and read-write time out for BeegoRequest.
+func (r *Request) SetTimeout(connectTimeout, readWriteTimeout time.Duration) *Request {
+ r.setting.ConnectTimeout = connectTimeout
+ r.setting.ReadWriteTimeout = readWriteTimeout
+ return r
+}
+
+func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request {
+ r.setting.ReadWriteTimeout = readWriteTimeout
+ return r
+}
+
+// SetTLSClientConfig sets tls connection configurations if visiting https url.
+func (r *Request) SetTLSClientConfig(config *tls.Config) *Request {
+ r.setting.TLSClientConfig = config
+ return r
+}
+
+// Header add header item string in request.
+func (r *Request) Header(key, value string) *Request {
+ r.req.Header.Set(key, value)
+ return r
+}
+
+// SetTransport sets transport to
+func (r *Request) SetTransport(transport http.RoundTripper) *Request {
+ r.setting.Transport = transport
+ return r
+}
+
+// Param adds query param in to request.
+// params build query string as ?key1=value1&key2=value2...
+func (r *Request) Param(key, value string) *Request {
+ r.params[key] = value
+ return r
+}
+
+// Body adds request raw body.
+// it supports string and []byte.
+func (r *Request) Body(data any) *Request {
+ switch t := data.(type) {
+ case string:
+ bf := bytes.NewBufferString(t)
+ r.req.Body = io.NopCloser(bf)
+ r.req.ContentLength = int64(len(t))
+ case []byte:
+ bf := bytes.NewBuffer(t)
+ r.req.Body = io.NopCloser(bf)
+ r.req.ContentLength = int64(len(t))
+ }
+ return r
+}
+
+func (r *Request) getResponse() (*http.Response, error) {
+ if r.resp.StatusCode != 0 {
+ return r.resp, nil
+ }
+
+ var paramBody string
+ if len(r.params) > 0 {
+ var buf bytes.Buffer
+ for k, v := range r.params {
+ buf.WriteString(url.QueryEscape(k))
+ buf.WriteByte('=')
+ buf.WriteString(url.QueryEscape(v))
+ buf.WriteByte('&')
+ }
+ paramBody = buf.String()
+ paramBody = paramBody[0 : len(paramBody)-1]
+ }
+
+ if r.req.Method == "GET" && len(paramBody) > 0 {
+ if strings.Contains(r.url, "?") {
+ r.url += "&" + paramBody
+ } else {
+ r.url = r.url + "?" + paramBody
+ }
+ } else if r.req.Method == "POST" && r.req.Body == nil && len(paramBody) > 0 {
+ r.Header("Content-Type", "application/x-www-form-urlencoded")
+ r.Body(paramBody)
+ }
+
+ var err error
+ r.req.URL, err = url.Parse(r.url)
+ if err != nil {
+ return nil, err
+ }
+
+ trans := r.setting.Transport
+ if trans == nil {
+ // create default transport
+ trans = &http.Transport{
+ TLSClientConfig: r.setting.TLSClientConfig,
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: TimeoutDialer(r.setting.ConnectTimeout),
+ }
+ } else if t, ok := trans.(*http.Transport); ok {
+ if t.TLSClientConfig == nil {
+ t.TLSClientConfig = r.setting.TLSClientConfig
+ }
+ if t.DialContext == nil {
+ t.DialContext = TimeoutDialer(r.setting.ConnectTimeout)
+ }
+ }
+
+ client := &http.Client{
+ Transport: trans,
+ Timeout: r.setting.ReadWriteTimeout,
+ }
+
+ if len(r.setting.UserAgent) > 0 && len(r.req.Header.Get("User-Agent")) == 0 {
+ r.req.Header.Set("User-Agent", r.setting.UserAgent)
+ }
+
+ resp, err := client.Do(r.req)
+ if err != nil {
+ return nil, err
+ }
+ r.resp = resp
+ return resp, nil
+}
+
+// Response executes request client gets response manually.
+func (r *Request) Response() (*http.Response, error) {
+ return r.getResponse()
+}
+
+// TimeoutDialer returns functions of connection dialer with timeout settings for http.Transport Dial field.
+func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr string) (c net.Conn, err error) {
+ return func(ctx context.Context, netw, addr string) (net.Conn, error) {
+ d := net.Dialer{Timeout: cTimeout}
+ conn, err := d.DialContext(ctx, netw, addr)
+ if err != nil {
+ return nil, err
+ }
+ return conn, nil
+ }
+}
+
+func (r *Request) GoString() string {
+ return fmt.Sprintf("%s %s", r.req.Method, r.url)
+}
diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go
new file mode 100644
index 0000000..2e3e6a7
--- /dev/null
+++ b/modules/httplib/serve.go
@@ -0,0 +1,237 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httplib
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ charsetModule "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/httpcache"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/klauspost/compress/gzhttp"
+)
+
+type ServeHeaderOptions struct {
+ ContentType string // defaults to "application/octet-stream"
+ ContentTypeCharset string
+ ContentLength *int64
+ Disposition string // defaults to "attachment"
+ Filename string
+ CacheDuration time.Duration // defaults to 5 minutes
+ LastModified time.Time
+}
+
+// ServeSetHeaders sets necessary content serve headers
+func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
+ header := w.Header()
+
+ skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
+ if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) {
+ w.Header().Add(gzhttp.HeaderNoCompression, "1")
+ }
+
+ contentType := typesniffer.ApplicationOctetStream
+ if opts.ContentType != "" {
+ if opts.ContentTypeCharset != "" {
+ contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset)
+ } else {
+ contentType = opts.ContentType
+ }
+ }
+ header.Set("Content-Type", contentType)
+ header.Set("X-Content-Type-Options", "nosniff")
+
+ if opts.ContentLength != nil {
+ header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
+ }
+
+ if opts.Filename != "" {
+ disposition := opts.Disposition
+ if disposition == "" {
+ disposition = "attachment"
+ }
+
+ backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \"
+ header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename)))
+ header.Set("Access-Control-Expose-Headers", "Content-Disposition")
+ }
+
+ duration := opts.CacheDuration
+ if duration == 0 {
+ duration = 5 * time.Minute
+ }
+ httpcache.SetCacheControlInHeader(header, duration)
+
+ if !opts.LastModified.IsZero() {
+ // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
+ header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat))
+ }
+}
+
+// ServeData download file from io.Reader
+func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, filePath string, mineBuf []byte) {
+ // do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests
+ opts := &ServeHeaderOptions{
+ Filename: path.Base(filePath),
+ }
+
+ sniffedType := typesniffer.DetectContentType(mineBuf)
+
+ // the "render" parameter came from year 2016: 638dd24c, it doesn't have clear meaning, so I think it could be removed later
+ isPlain := sniffedType.IsText() || r.FormValue("render") != ""
+
+ if setting.MimeTypeMap.Enabled {
+ fileExtension := strings.ToLower(filepath.Ext(filePath))
+ opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
+ }
+
+ if opts.ContentType == "" {
+ if sniffedType.IsBrowsableBinaryType() {
+ opts.ContentType = sniffedType.GetMimeType()
+ } else if isPlain {
+ opts.ContentType = "text/plain"
+ } else {
+ opts.ContentType = typesniffer.ApplicationOctetStream
+ }
+ }
+
+ if isPlain {
+ charset, err := charsetModule.DetectEncoding(mineBuf)
+ if err != nil {
+ log.Error("Detect raw file %s charset failed: %v, using by default utf-8", filePath, err)
+ charset = "utf-8"
+ }
+ opts.ContentTypeCharset = strings.ToLower(charset)
+ }
+
+ isSVG := sniffedType.IsSvgImage()
+
+ // serve types that can present a security risk with CSP
+ if isSVG {
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
+ } else if sniffedType.IsPDF() {
+ // no sandbox attribute for pdf as it breaks rendering in at least safari. this
+ // should generally be safe as scripts inside PDF can not escape the PDF document
+ // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
+ }
+
+ opts.Disposition = "inline"
+ if isSVG && !setting.UI.SVG.Enabled {
+ opts.Disposition = "attachment"
+ }
+
+ ServeSetHeaders(w, opts)
+}
+
+const mimeDetectionBufferLen = 1024
+
+func ServeContentByReader(r *http.Request, w http.ResponseWriter, filePath string, size int64, reader io.Reader) {
+ buf := make([]byte, mimeDetectionBufferLen)
+ n, err := util.ReadAtMost(reader, buf)
+ if err != nil {
+ http.Error(w, "serve content: unable to pre-read", http.StatusRequestedRangeNotSatisfiable)
+ return
+ }
+ if n >= 0 {
+ buf = buf[:n]
+ }
+ setServeHeadersByFile(r, w, filePath, buf)
+
+ // reset the reader to the beginning
+ reader = io.MultiReader(bytes.NewReader(buf), reader)
+
+ rangeHeader := r.Header.Get("Range")
+
+ // if no size or no supported range, serve as 200 (complete response)
+ if size <= 0 || !strings.HasPrefix(rangeHeader, "bytes=") {
+ if size >= 0 {
+ w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
+ }
+ _, _ = io.Copy(w, reader) // just like http.ServeContent, not necessary to handle the error
+ return
+ }
+
+ // do our best to support the minimal "Range" request (no support for multiple range: "Range: bytes=0-50, 100-150")
+ //
+ // GET /...
+ // Range: bytes=0-1023
+ //
+ // HTTP/1.1 206 Partial Content
+ // Content-Range: bytes 0-1023/146515
+ // Content-Length: 1024
+
+ _, rangeParts, _ := strings.Cut(rangeHeader, "=")
+ rangeBytesStart, rangeBytesEnd, found := strings.Cut(rangeParts, "-")
+ start, err := strconv.ParseInt(rangeBytesStart, 10, 64)
+ if start < 0 || start >= size {
+ err = errors.New("invalid start range")
+ }
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
+ return
+ }
+ end, err := strconv.ParseInt(rangeBytesEnd, 10, 64)
+ if rangeBytesEnd == "" && found {
+ err = nil
+ end = size - 1
+ }
+ if end >= size {
+ end = size - 1
+ }
+ if end < start {
+ err = errors.New("invalid end range")
+ }
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ partialLength := end - start + 1
+ w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
+ w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10))
+ if _, err = io.CopyN(io.Discard, reader, start); err != nil {
+ http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error
+}
+
+func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, filePath string, modTime *time.Time, reader io.ReadSeeker) {
+ buf := make([]byte, mimeDetectionBufferLen)
+ n, err := util.ReadAtMost(reader, buf)
+ if err != nil {
+ http.Error(w, "serve content: unable to read", http.StatusInternalServerError)
+ return
+ }
+ if _, err = reader.Seek(0, io.SeekStart); err != nil {
+ http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
+ return
+ }
+ if n >= 0 {
+ buf = buf[:n]
+ }
+ setServeHeadersByFile(r, w, filePath, buf)
+ if modTime == nil {
+ modTime = &time.Time{}
+ }
+ http.ServeContent(w, r, path.Base(filePath), *modTime, reader)
+}
diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go
new file mode 100644
index 0000000..fe609e1
--- /dev/null
+++ b/modules/httplib/serve_test.go
@@ -0,0 +1,109 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httplib
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestServeContentByReader(t *testing.T) {
+ data := "0123456789abcdef"
+
+ test := func(t *testing.T, expectedStatusCode int, expectedContent string) {
+ _, rangeStr, _ := strings.Cut(t.Name(), "_range_")
+ r := &http.Request{Header: http.Header{}, Form: url.Values{}}
+ if rangeStr != "" {
+ r.Header.Set("Range", fmt.Sprintf("bytes=%s", rangeStr))
+ }
+ reader := strings.NewReader(data)
+ w := httptest.NewRecorder()
+ ServeContentByReader(r, w, "test", int64(len(data)), reader)
+ assert.Equal(t, expectedStatusCode, w.Code)
+ if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
+ assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length"))
+ assert.Equal(t, expectedContent, w.Body.String())
+ }
+ }
+
+ t.Run("_range_", func(t *testing.T) {
+ test(t, http.StatusOK, data)
+ })
+ t.Run("_range_0-", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data)
+ })
+ t.Run("_range_0-15", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data)
+ })
+ t.Run("_range_1-", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:])
+ })
+ t.Run("_range_1-3", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:3+1])
+ })
+ t.Run("_range_16-", func(t *testing.T) {
+ test(t, http.StatusRequestedRangeNotSatisfiable, "")
+ })
+ t.Run("_range_1-99999", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:])
+ })
+}
+
+func TestServeContentByReadSeeker(t *testing.T) {
+ data := "0123456789abcdef"
+ tmpFile := t.TempDir() + "/test"
+ err := os.WriteFile(tmpFile, []byte(data), 0o644)
+ require.NoError(t, err)
+
+ test := func(t *testing.T, expectedStatusCode int, expectedContent string) {
+ _, rangeStr, _ := strings.Cut(t.Name(), "_range_")
+ r := &http.Request{Header: http.Header{}, Form: url.Values{}}
+ if rangeStr != "" {
+ r.Header.Set("Range", fmt.Sprintf("bytes=%s", rangeStr))
+ }
+
+ seekReader, err := os.OpenFile(tmpFile, os.O_RDONLY, 0o644)
+ require.NoError(t, err)
+
+ defer seekReader.Close()
+
+ w := httptest.NewRecorder()
+ ServeContentByReadSeeker(r, w, "test", nil, seekReader)
+ assert.Equal(t, expectedStatusCode, w.Code)
+ if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
+ assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length"))
+ assert.Equal(t, expectedContent, w.Body.String())
+ }
+ }
+
+ t.Run("_range_", func(t *testing.T) {
+ test(t, http.StatusOK, data)
+ })
+ t.Run("_range_0-", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data)
+ })
+ t.Run("_range_0-15", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data)
+ })
+ t.Run("_range_1-", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:])
+ })
+ t.Run("_range_1-3", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:3+1])
+ })
+ t.Run("_range_16-", func(t *testing.T) {
+ test(t, http.StatusRequestedRangeNotSatisfiable, "")
+ })
+ t.Run("_range_1-99999", func(t *testing.T) {
+ test(t, http.StatusPartialContent, data[1:])
+ })
+}
diff --git a/modules/httplib/url.go b/modules/httplib/url.go
new file mode 100644
index 0000000..14b9589
--- /dev/null
+++ b/modules/httplib/url.go
@@ -0,0 +1,27 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httplib
+
+import (
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// IsRiskyRedirectURL returns true if the URL is considered risky for redirects
+func IsRiskyRedirectURL(s string) bool {
+ // Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
+ // Therefore we should ignore these redirect locations to prevent open redirects
+ if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
+ return true
+ }
+
+ u, err := url.Parse(s)
+ if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(s), strings.ToLower(setting.AppURL))) {
+ return true
+ }
+
+ return false
+}
diff --git a/modules/httplib/url_test.go b/modules/httplib/url_test.go
new file mode 100644
index 0000000..2842edd
--- /dev/null
+++ b/modules/httplib/url_test.go
@@ -0,0 +1,123 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package httplib
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsRiskyRedirectURL(t *testing.T) {
+ defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
+ defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"", false},
+ {"foo", false},
+ {"./", false},
+ {"?key=val", false},
+ {"/sub/", false},
+ {"http://localhost:3000/sub/", false},
+ {"/sub/foo", false},
+ {"http://localhost:3000/sub/foo", false},
+ {"http://localhost:3000/sub/test?param=false", false},
+ // FIXME: should probably be true (would requires resolving references using setting.appURL.ResolveReference(u))
+ {"/sub/../", false},
+ {"http://localhost:3000/sub/../", false},
+ {"/sUb/", false},
+ {"http://localhost:3000/sUb/foo", false},
+ {"/sub", false},
+ {"/foo?k=%20#abc", false},
+ {"/", false},
+ {"a/", false},
+ {"test?param=false", false},
+ {"/hey/hey/hey#3244", false},
+
+ {"//", true},
+ {"\\\\", true},
+ {"/\\", true},
+ {"\\/", true},
+ {"mail:a@b.com", true},
+ {"https://test.com", true},
+ {"http://localhost:3000/foo", true},
+ {"http://localhost:3000/sub", true},
+ {"http://localhost:3000/sub?key=val", true},
+ {"https://example.com/", true},
+ {"//example.com", true},
+ {"http://example.com", true},
+ {"http://localhost:3000/test?param=false", true},
+ {"//localhost:3000/test?param=false", true},
+ {"://missing protocol scheme", true},
+ // FIXME: should probably be false
+ {"//localhost:3000/sub/test?param=false", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input))
+ })
+ }
+}
+
+func TestIsRiskyRedirectURLWithoutSubURL(t *testing.T) {
+ defer test.MockVariableValue(&setting.AppURL, "https://next.forgejo.org/")()
+ defer test.MockVariableValue(&setting.AppSubURL, "")()
+
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"", false},
+ {"foo", false},
+ {"./", false},
+ {"?key=val", false},
+ {"/sub/", false},
+ {"https://next.forgejo.org/sub/", false},
+ {"/sub/foo", false},
+ {"https://next.forgejo.org/sub/foo", false},
+ {"https://next.forgejo.org/sub/test?param=false", false},
+ {"https://next.forgejo.org/sub/../", false},
+ {"/sub/../", false},
+ {"/sUb/", false},
+ {"https://next.forgejo.org/sUb/foo", false},
+ {"/sub", false},
+ {"/foo?k=%20#abc", false},
+ {"/", false},
+ {"a/", false},
+ {"test?param=false", false},
+ {"/hey/hey/hey#3244", false},
+ {"https://next.forgejo.org/test?param=false", false},
+ {"https://next.forgejo.org/foo", false},
+ {"https://next.forgejo.org/sub", false},
+ {"https://next.forgejo.org/sub?key=val", false},
+
+ {"//", true},
+ {"\\\\", true},
+ {"/\\", true},
+ {"\\/", true},
+ {"mail:a@b.com", true},
+ {"https://test.com", true},
+ {"https://example.com/", true},
+ {"//example.com", true},
+ {"http://example.com", true},
+ {"://missing protocol scheme", true},
+ {"https://forgejo.org", true},
+ {"https://example.org?url=https://next.forgejo.org", true},
+ // FIXME: should probably be false
+ {"https://next.forgejo.org", true},
+ {"//next.forgejo.org/test?param=false", true},
+ {"//next.forgejo.org/sub/test?param=false", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input))
+ })
+ }
+}
diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go
new file mode 100644
index 0000000..cf9fcbd
--- /dev/null
+++ b/modules/indexer/code/bleve/bleve.go
@@ -0,0 +1,354 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/analyze"
+ "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/typesniffer"
+
+ "github.com/blevesearch/bleve/v2"
+ analyzer_custom "github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
+ analyzer_keyword "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
+ "github.com/blevesearch/bleve/v2/analysis/token/camelcase"
+ "github.com/blevesearch/bleve/v2/analysis/token/lowercase"
+ "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
+ "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
+ "github.com/blevesearch/bleve/v2/mapping"
+ "github.com/blevesearch/bleve/v2/search/query"
+ "github.com/go-enry/go-enry/v2"
+)
+
+const (
+ unicodeNormalizeName = "unicodeNormalize"
+ maxBatchSize = 16
+ // fuzzyDenominator determines the levenshtein distance per each character of a keyword
+ fuzzyDenominator = 4
+ // see https://github.com/blevesearch/bleve/issues/1563#issuecomment-786822311
+ maxFuzziness = 2
+)
+
+func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error {
+ return m.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
+ "type": unicodenorm.Name,
+ "form": unicodenorm.NFC,
+ })
+}
+
+// RepoIndexerData data stored in the repo indexer
+type RepoIndexerData struct {
+ RepoID int64
+ CommitID string
+ Content string
+ Language string
+ UpdatedAt time.Time
+}
+
+// Type returns the document type, for bleve's mapping.Classifier interface.
+func (d *RepoIndexerData) Type() string {
+ return repoIndexerDocType
+}
+
+const (
+ repoIndexerAnalyzer = "repoIndexerAnalyzer"
+ repoIndexerDocType = "repoIndexerDocType"
+ repoIndexerLatestVersion = 6
+)
+
+// generateBleveIndexMapping generates a bleve index mapping for the repo indexer
+func generateBleveIndexMapping() (mapping.IndexMapping, error) {
+ docMapping := bleve.NewDocumentMapping()
+ numericFieldMapping := bleve.NewNumericFieldMapping()
+ numericFieldMapping.IncludeInAll = false
+ docMapping.AddFieldMappingsAt("RepoID", numericFieldMapping)
+
+ textFieldMapping := bleve.NewTextFieldMapping()
+ textFieldMapping.IncludeInAll = false
+ docMapping.AddFieldMappingsAt("Content", textFieldMapping)
+
+ termFieldMapping := bleve.NewTextFieldMapping()
+ termFieldMapping.IncludeInAll = false
+ termFieldMapping.Analyzer = analyzer_keyword.Name
+ docMapping.AddFieldMappingsAt("Language", termFieldMapping)
+ docMapping.AddFieldMappingsAt("CommitID", termFieldMapping)
+
+ timeFieldMapping := bleve.NewDateTimeFieldMapping()
+ timeFieldMapping.IncludeInAll = false
+ docMapping.AddFieldMappingsAt("UpdatedAt", timeFieldMapping)
+
+ mapping := bleve.NewIndexMapping()
+ if err := addUnicodeNormalizeTokenFilter(mapping); err != nil {
+ return nil, err
+ } else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{
+ "type": analyzer_custom.Name,
+ "char_filters": []string{},
+ "tokenizer": unicode.Name,
+ "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
+ }); err != nil {
+ return nil, err
+ }
+ mapping.DefaultAnalyzer = repoIndexerAnalyzer
+ mapping.AddDocumentMapping(repoIndexerDocType, docMapping)
+ mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
+
+ return mapping, nil
+}
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer represents a bleve indexer implementation
+type Indexer struct {
+ inner *inner_bleve.Indexer
+ indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much
+}
+
+// NewIndexer creates a new bleve local indexer
+func NewIndexer(indexDir string) *Indexer {
+ inner := inner_bleve.NewIndexer(indexDir, repoIndexerLatestVersion, generateBleveIndexMapping)
+ return &Indexer{
+ Indexer: inner,
+ inner: inner,
+ }
+}
+
+func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, commitSha string,
+ update internal.FileUpdate, repo *repo_model.Repository, batch *inner_bleve.FlushingBatch,
+) error {
+ // Ignore vendored files in code search
+ if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) {
+ return nil
+ }
+
+ size := update.Size
+
+ var err error
+ if !update.Sized {
+ var stdout string
+ stdout, _, err = git.NewCommand(ctx, "cat-file", "-s").AddDynamicArguments(update.BlobSha).RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ if err != nil {
+ return err
+ }
+ if size, err = strconv.ParseInt(strings.TrimSpace(stdout), 10, 64); err != nil {
+ return fmt.Errorf("misformatted git cat-file output: %w", err)
+ }
+ }
+
+ if size > setting.Indexer.MaxIndexerFileSize {
+ return b.addDelete(update.Filename, repo, batch)
+ }
+
+ if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil {
+ return err
+ }
+
+ _, _, size, err = git.ReadBatchLine(batchReader)
+ if err != nil {
+ return err
+ }
+
+ fileContents, err := io.ReadAll(io.LimitReader(batchReader, size))
+ if err != nil {
+ return err
+ } else if !typesniffer.DetectContentType(fileContents).IsText() {
+ // FIXME: UTF-16 files will probably fail here
+ return nil
+ }
+
+ if _, err = batchReader.Discard(1); err != nil {
+ return err
+ }
+ id := internal.FilenameIndexerID(repo.ID, update.Filename)
+ return batch.Index(id, &RepoIndexerData{
+ RepoID: repo.ID,
+ CommitID: commitSha,
+ Content: string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})),
+ Language: analyze.GetCodeLanguage(update.Filename, fileContents),
+ UpdatedAt: time.Now().UTC(),
+ })
+}
+
+func (b *Indexer) addDelete(filename string, repo *repo_model.Repository, batch *inner_bleve.FlushingBatch) error {
+ id := internal.FilenameIndexerID(repo.ID, filename)
+ return batch.Delete(id)
+}
+
+// Index indexes the data
+func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error {
+ batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize)
+ if len(changes.Updates) > 0 {
+ r, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ gitBatch, err := r.NewBatch(ctx)
+ if err != nil {
+ return err
+ }
+ defer gitBatch.Close()
+
+ for _, update := range changes.Updates {
+ if err := b.addUpdate(ctx, gitBatch.Writer, gitBatch.Reader, sha, update, repo, batch); err != nil {
+ return err
+ }
+ }
+ gitBatch.Close()
+ }
+ for _, filename := range changes.RemovedFilenames {
+ if err := b.addDelete(filename, repo, batch); err != nil {
+ return err
+ }
+ }
+ return batch.Flush()
+}
+
+// Delete deletes indexes by ids
+func (b *Indexer) Delete(_ context.Context, repoID int64) error {
+ query := inner_bleve.NumericEqualityQuery(repoID, "RepoID")
+ searchRequest := bleve.NewSearchRequestOptions(query, 2147483647, 0, false)
+ result, err := b.inner.Indexer.Search(searchRequest)
+ if err != nil {
+ return err
+ }
+ batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize)
+ for _, hit := range result.Hits {
+ if err = batch.Delete(hit.ID); err != nil {
+ return err
+ }
+ }
+ return batch.Flush()
+}
+
+// Search searches for files in the specified repo.
+// Returns the matching file-paths
+func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
+ var (
+ indexerQuery query.Query
+ keywordQuery query.Query
+ )
+
+ phraseQuery := bleve.NewMatchPhraseQuery(opts.Keyword)
+ phraseQuery.FieldVal = "Content"
+ phraseQuery.Analyzer = repoIndexerAnalyzer
+ keywordQuery = phraseQuery
+ if opts.IsKeywordFuzzy {
+ phraseQuery.Fuzziness = min(maxFuzziness, len(opts.Keyword)/fuzzyDenominator)
+ }
+
+ if len(opts.RepoIDs) > 0 {
+ repoQueries := make([]query.Query, 0, len(opts.RepoIDs))
+ for _, repoID := range opts.RepoIDs {
+ repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "RepoID"))
+ }
+
+ indexerQuery = bleve.NewConjunctionQuery(
+ bleve.NewDisjunctionQuery(repoQueries...),
+ keywordQuery,
+ )
+ } else {
+ indexerQuery = keywordQuery
+ }
+
+ // Save for reuse without language filter
+ facetQuery := indexerQuery
+ if len(opts.Language) > 0 {
+ languageQuery := bleve.NewMatchQuery(opts.Language)
+ languageQuery.FieldVal = "Language"
+ languageQuery.Analyzer = analyzer_keyword.Name
+
+ indexerQuery = bleve.NewConjunctionQuery(
+ indexerQuery,
+ languageQuery,
+ )
+ }
+
+ from, pageSize := opts.GetSkipTake()
+ searchRequest := bleve.NewSearchRequestOptions(indexerQuery, pageSize, from, false)
+ searchRequest.Fields = []string{"Content", "RepoID", "Language", "CommitID", "UpdatedAt"}
+ searchRequest.IncludeLocations = true
+
+ if len(opts.Language) == 0 {
+ searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
+ }
+
+ result, err := b.inner.Indexer.SearchInContext(ctx, searchRequest)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+
+ total := int64(result.Total)
+
+ searchResults := make([]*internal.SearchResult, len(result.Hits))
+ for i, hit := range result.Hits {
+ startIndex, endIndex := -1, -1
+ for _, locations := range hit.Locations["Content"] {
+ location := locations[0]
+ locationStart := int(location.Start)
+ locationEnd := int(location.End)
+ if startIndex < 0 || locationStart < startIndex {
+ startIndex = locationStart
+ }
+ if endIndex < 0 || locationEnd > endIndex {
+ endIndex = locationEnd
+ }
+ }
+ language := hit.Fields["Language"].(string)
+ var updatedUnix timeutil.TimeStamp
+ if t, err := time.Parse(time.RFC3339, hit.Fields["UpdatedAt"].(string)); err == nil {
+ updatedUnix = timeutil.TimeStamp(t.Unix())
+ }
+ searchResults[i] = &internal.SearchResult{
+ RepoID: int64(hit.Fields["RepoID"].(float64)),
+ StartIndex: startIndex,
+ EndIndex: endIndex,
+ Filename: internal.FilenameOfIndexerID(hit.ID),
+ Content: hit.Fields["Content"].(string),
+ CommitID: hit.Fields["CommitID"].(string),
+ UpdatedUnix: updatedUnix,
+ Language: language,
+ Color: enry.GetColor(language),
+ }
+ }
+
+ searchResultLanguages := make([]*internal.SearchResultLanguages, 0, 10)
+ if len(opts.Language) > 0 {
+ // Use separate query to go get all language counts
+ facetRequest := bleve.NewSearchRequestOptions(facetQuery, 1, 0, false)
+ facetRequest.Fields = []string{"Content", "RepoID", "Language", "CommitID", "UpdatedAt"}
+ facetRequest.IncludeLocations = true
+ facetRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
+
+ if result, err = b.inner.Indexer.Search(facetRequest); err != nil {
+ return 0, nil, nil, err
+ }
+ }
+ languagesFacet := result.Facets["languages"]
+ for _, term := range languagesFacet.Terms.Terms() {
+ if len(term.Term) == 0 {
+ continue
+ }
+ searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{
+ Language: term.Term,
+ Color: enry.GetColor(term.Term),
+ Count: term.Count,
+ })
+ }
+ return total, searchResults, searchResultLanguages, nil
+}
diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go
new file mode 100644
index 0000000..aee5668
--- /dev/null
+++ b/modules/indexer/code/elasticsearch/elasticsearch.go
@@ -0,0 +1,388 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/analyze"
+ "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/typesniffer"
+
+ "github.com/go-enry/go-enry/v2"
+ "github.com/olivere/elastic/v7"
+)
+
+const (
+ esRepoIndexerLatestVersion = 1
+ // multi-match-types, currently only 2 types are used
+ // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
+ esMultiMatchTypeBestFields = "best_fields"
+ esMultiMatchTypePhrasePrefix = "phrase_prefix"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer implements Indexer interface
+type Indexer struct {
+ inner *inner_elasticsearch.Indexer
+ indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
+}
+
+// NewIndexer creates a new elasticsearch indexer
+func NewIndexer(url, indexerName string) *Indexer {
+ inner := inner_elasticsearch.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping)
+ indexer := &Indexer{
+ inner: inner,
+ Indexer: inner,
+ }
+ return indexer
+}
+
+const (
+ defaultMapping = `{
+ "mappings": {
+ "properties": {
+ "repo_id": {
+ "type": "long",
+ "index": true
+ },
+ "content": {
+ "type": "text",
+ "term_vector": "with_positions_offsets",
+ "index": true
+ },
+ "commit_id": {
+ "type": "keyword",
+ "index": true
+ },
+ "language": {
+ "type": "keyword",
+ "index": true
+ },
+ "updated_at": {
+ "type": "long",
+ "index": true
+ }
+ }
+ }
+ }`
+)
+
+func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) {
+ // Ignore vendored files in code search
+ if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) {
+ return nil, nil
+ }
+
+ size := update.Size
+ var err error
+ if !update.Sized {
+ var stdout string
+ stdout, _, err = git.NewCommand(ctx, "cat-file", "-s").AddDynamicArguments(update.BlobSha).RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ if err != nil {
+ return nil, err
+ }
+ if size, err = strconv.ParseInt(strings.TrimSpace(stdout), 10, 64); err != nil {
+ return nil, fmt.Errorf("misformatted git cat-file output: %w", err)
+ }
+ }
+
+ if size > setting.Indexer.MaxIndexerFileSize {
+ return []elastic.BulkableRequest{b.addDelete(update.Filename, repo)}, nil
+ }
+
+ if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil {
+ return nil, err
+ }
+
+ _, _, size, err = git.ReadBatchLine(batchReader)
+ if err != nil {
+ return nil, err
+ }
+
+ fileContents, err := io.ReadAll(io.LimitReader(batchReader, size))
+ if err != nil {
+ return nil, err
+ } else if !typesniffer.DetectContentType(fileContents).IsText() {
+ // FIXME: UTF-16 files will probably fail here
+ return nil, nil
+ }
+
+ if _, err = batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+ id := internal.FilenameIndexerID(repo.ID, update.Filename)
+
+ return []elastic.BulkableRequest{
+ elastic.NewBulkIndexRequest().
+ Index(b.inner.VersionedIndexName()).
+ Id(id).
+ Doc(map[string]any{
+ "repo_id": repo.ID,
+ "content": string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})),
+ "commit_id": sha,
+ "language": analyze.GetCodeLanguage(update.Filename, fileContents),
+ "updated_at": timeutil.TimeStampNow(),
+ }),
+ }, nil
+}
+
+func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) elastic.BulkableRequest {
+ id := internal.FilenameIndexerID(repo.ID, filename)
+ return elastic.NewBulkDeleteRequest().
+ Index(b.inner.VersionedIndexName()).
+ Id(id)
+}
+
+// Index will save the index data
+func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error {
+ reqs := make([]elastic.BulkableRequest, 0)
+ if len(changes.Updates) > 0 {
+ r, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ batch, err := r.NewBatch(ctx)
+ if err != nil {
+ return err
+ }
+ defer batch.Close()
+
+ for _, update := range changes.Updates {
+ updateReqs, err := b.addUpdate(ctx, batch.Writer, batch.Reader, sha, update, repo)
+ if err != nil {
+ return err
+ }
+ if len(updateReqs) > 0 {
+ reqs = append(reqs, updateReqs...)
+ }
+ }
+ batch.Close()
+ }
+
+ for _, filename := range changes.RemovedFilenames {
+ reqs = append(reqs, b.addDelete(filename, repo))
+ }
+
+ if len(reqs) > 0 {
+ esBatchSize := 50
+
+ for i := 0; i < len(reqs); i += esBatchSize {
+ _, err := b.inner.Client.Bulk().
+ Index(b.inner.VersionedIndexName()).
+ Add(reqs[i:min(i+esBatchSize, len(reqs))]...).
+ Do(ctx)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// Delete entries by repoId
+func (b *Indexer) Delete(ctx context.Context, repoID int64) error {
+ if err := b.doDelete(ctx, repoID); err != nil {
+ // Maybe there is a conflict during the delete operation, so we should retry after a refresh
+ log.Warn("Deletion of entries of repo %v within index %v was erroneus. Trying to refresh index before trying again", repoID, b.inner.VersionedIndexName(), err)
+ if err := b.refreshIndex(ctx); err != nil {
+ return err
+ }
+ if err := b.doDelete(ctx, repoID); err != nil {
+ log.Error("Could not delete entries of repo %v within index %v", repoID, b.inner.VersionedIndexName())
+ return err
+ }
+ }
+ return nil
+}
+
+func (b *Indexer) refreshIndex(ctx context.Context) error {
+ if _, err := b.inner.Client.Refresh(b.inner.VersionedIndexName()).Do(ctx); err != nil {
+ log.Error("Error while trying to refresh index %v", b.inner.VersionedIndexName(), err)
+ return err
+ }
+
+ return nil
+}
+
+// Delete entries by repoId
+func (b *Indexer) doDelete(ctx context.Context, repoID int64) error {
+ _, err := b.inner.Client.DeleteByQuery(b.inner.VersionedIndexName()).
+ Query(elastic.NewTermsQuery("repo_id", repoID)).
+ Do(ctx)
+ return err
+}
+
+// indexPos find words positions for start and the following end on content. It will
+// return the beginning position of the first start and the ending position of the
+// first end following the start string.
+// If not found any of the positions, it will return -1, -1.
+func indexPos(content, start, end string) (int, int) {
+ startIdx := strings.Index(content, start)
+ if startIdx < 0 {
+ return -1, -1
+ }
+ endIdx := strings.Index(content[startIdx+len(start):], end)
+ if endIdx < 0 {
+ return -1, -1
+ }
+ return startIdx, startIdx + len(start) + endIdx + len(end)
+}
+
+func convertResult(searchResult *elastic.SearchResult, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
+ hits := make([]*internal.SearchResult, 0, pageSize)
+ for _, hit := range searchResult.Hits.Hits {
+ // FIXME: There is no way to get the position the keyword on the content currently on the same request.
+ // So we get it from content, this may made the query slower. See
+ // https://discuss.elastic.co/t/fetching-position-of-keyword-in-matched-document/94291
+ var startIndex, endIndex int
+ c, ok := hit.Highlight["content"]
+ if ok && len(c) > 0 {
+ // FIXME: Since the highlighting content will include <em> and </em> for the keywords,
+ // now we should find the positions. But how to avoid html content which contains the
+ // <em> and </em> tags? If elastic search has handled that?
+ startIndex, endIndex = indexPos(c[0], "<em>", "</em>")
+ if startIndex == -1 {
+ panic(fmt.Sprintf("1===%s,,,%#v,,,%s", kw, hit.Highlight, c[0]))
+ }
+ } else {
+ panic(fmt.Sprintf("2===%#v", hit.Highlight))
+ }
+
+ repoID, fileName := internal.ParseIndexerID(hit.Id)
+ res := make(map[string]any)
+ if err := json.Unmarshal(hit.Source, &res); err != nil {
+ return 0, nil, nil, err
+ }
+
+ language := res["language"].(string)
+
+ hits = append(hits, &internal.SearchResult{
+ RepoID: repoID,
+ Filename: fileName,
+ CommitID: res["commit_id"].(string),
+ Content: res["content"].(string),
+ UpdatedUnix: timeutil.TimeStamp(res["updated_at"].(float64)),
+ Language: language,
+ StartIndex: startIndex,
+ EndIndex: endIndex - 9, // remove the length <em></em> since we give Content the original data
+ Color: enry.GetColor(language),
+ })
+ }
+
+ return searchResult.TotalHits(), hits, extractAggs(searchResult), nil
+}
+
+func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLanguages {
+ var searchResultLanguages []*internal.SearchResultLanguages
+ agg, found := searchResult.Aggregations.Terms("language")
+ if found {
+ searchResultLanguages = make([]*internal.SearchResultLanguages, 0, 10)
+
+ for _, bucket := range agg.Buckets {
+ searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{
+ Language: bucket.Key.(string),
+ Color: enry.GetColor(bucket.Key.(string)),
+ Count: int(bucket.DocCount),
+ })
+ }
+ }
+ return searchResultLanguages
+}
+
+// Search searches for codes and language stats by given conditions.
+func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
+ searchType := esMultiMatchTypePhrasePrefix
+ if opts.IsKeywordFuzzy {
+ searchType = esMultiMatchTypeBestFields
+ }
+
+ kwQuery := elastic.NewMultiMatchQuery(opts.Keyword, "content").Type(searchType)
+ query := elastic.NewBoolQuery()
+ query = query.Must(kwQuery)
+ if len(opts.RepoIDs) > 0 {
+ repoStrs := make([]any, 0, len(opts.RepoIDs))
+ for _, repoID := range opts.RepoIDs {
+ repoStrs = append(repoStrs, repoID)
+ }
+ repoQuery := elastic.NewTermsQuery("repo_id", repoStrs...)
+ query = query.Must(repoQuery)
+ }
+
+ var (
+ start, pageSize = opts.GetSkipTake()
+ kw = "<em>" + opts.Keyword + "</em>"
+ aggregation = elastic.NewTermsAggregation().Field("language").Size(10).OrderByCountDesc()
+ )
+
+ if len(opts.Language) == 0 {
+ searchResult, err := b.inner.Client.Search().
+ Index(b.inner.VersionedIndexName()).
+ Aggregation("language", aggregation).
+ Query(query).
+ Highlight(
+ elastic.NewHighlight().
+ Field("content").
+ NumOfFragments(0). // return all highting content on fragments
+ HighlighterType("fvh"),
+ ).
+ Sort("repo_id", true).
+ From(start).Size(pageSize).
+ Do(ctx)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+
+ return convertResult(searchResult, kw, pageSize)
+ }
+
+ langQuery := elastic.NewMatchQuery("language", opts.Language)
+ countResult, err := b.inner.Client.Search().
+ Index(b.inner.VersionedIndexName()).
+ Aggregation("language", aggregation).
+ Query(query).
+ Size(0). // We only need stats information
+ Do(ctx)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+
+ query = query.Must(langQuery)
+ searchResult, err := b.inner.Client.Search().
+ Index(b.inner.VersionedIndexName()).
+ Query(query).
+ Highlight(
+ elastic.NewHighlight().
+ Field("content").
+ NumOfFragments(0). // return all highting content on fragments
+ HighlighterType("fvh"),
+ ).
+ Sort("repo_id", true).
+ From(start).Size(pageSize).
+ Do(ctx)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+
+ total, hits, _, err := convertResult(searchResult, kw, pageSize)
+
+ return total, hits, extractAggs(countResult), err
+}
diff --git a/modules/indexer/code/elasticsearch/elasticsearch_test.go b/modules/indexer/code/elasticsearch/elasticsearch_test.go
new file mode 100644
index 0000000..c6ba93e
--- /dev/null
+++ b/modules/indexer/code/elasticsearch/elasticsearch_test.go
@@ -0,0 +1,16 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIndexPos(t *testing.T) {
+ startIdx, endIdx := indexPos("test index start and end", "start", "end")
+ assert.EqualValues(t, 11, startIdx)
+ assert.EqualValues(t, 24, endIdx)
+}
diff --git a/modules/indexer/code/git.go b/modules/indexer/code/git.go
new file mode 100644
index 0000000..c7ffcfd
--- /dev/null
+++ b/modules/indexer/code/git.go
@@ -0,0 +1,199 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package code
+
+import (
+ "context"
+ "strconv"
+ "strings"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func getDefaultBranchSha(ctx context.Context, repo *repo_model.Repository) (string, error) {
+ stdout, _, err := git.NewCommand(ctx, "show-ref", "-s").AddDynamicArguments(git.BranchPrefix + repo.DefaultBranch).RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(stdout), nil
+}
+
+// getRepoChanges returns changes to repo since last indexer update
+func getRepoChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) {
+ status, err := repo_model.GetIndexerStatus(ctx, repo, repo_model.RepoIndexerTypeCode)
+ if err != nil {
+ return nil, err
+ }
+
+ needGenesis := len(status.CommitSha) == 0
+ if !needGenesis {
+ hasAncestorCmd := git.NewCommand(ctx, "merge-base").AddDynamicArguments(status.CommitSha, revision)
+ stdout, _, _ := hasAncestorCmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ needGenesis = len(stdout) == 0
+ }
+
+ if needGenesis {
+ return genesisChanges(ctx, repo, revision)
+ }
+ return nonGenesisChanges(ctx, repo, revision)
+}
+
+func isIndexable(entry *git.TreeEntry) bool {
+ if !entry.IsRegular() && !entry.IsExecutable() {
+ return false
+ }
+ name := strings.ToLower(entry.Name())
+ for _, g := range setting.Indexer.ExcludePatterns {
+ if g.Match(name) {
+ return false
+ }
+ }
+ for _, g := range setting.Indexer.IncludePatterns {
+ if g.Match(name) {
+ return true
+ }
+ }
+ return len(setting.Indexer.IncludePatterns) == 0
+}
+
+// parseGitLsTreeOutput parses the output of a `git ls-tree -r --full-name` command
+func parseGitLsTreeOutput(stdout []byte) ([]internal.FileUpdate, error) {
+ entries, err := git.ParseTreeEntries(stdout)
+ if err != nil {
+ return nil, err
+ }
+ idxCount := 0
+ updates := make([]internal.FileUpdate, len(entries))
+ for _, entry := range entries {
+ if isIndexable(entry) {
+ updates[idxCount] = internal.FileUpdate{
+ Filename: entry.Name(),
+ BlobSha: entry.ID.String(),
+ Size: entry.Size(),
+ Sized: true,
+ }
+ idxCount++
+ }
+ }
+ return updates[:idxCount], nil
+}
+
+// genesisChanges get changes to add repo to the indexer for the first time
+func genesisChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) {
+ var changes internal.RepoChanges
+ stdout, _, runErr := git.NewCommand(ctx, "ls-tree", "--full-tree", "-l", "-r").AddDynamicArguments(revision).RunStdBytes(&git.RunOpts{Dir: repo.RepoPath()})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ var err error
+ changes.Updates, err = parseGitLsTreeOutput(stdout)
+ return &changes, err
+}
+
+// nonGenesisChanges get changes since the previous indexer update
+func nonGenesisChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) {
+ diffCmd := git.NewCommand(ctx, "diff", "--name-status").AddDynamicArguments(repo.CodeIndexerStatus.CommitSha, revision)
+ stdout, _, runErr := diffCmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ if runErr != nil {
+ // previous commit sha may have been removed by a force push, so
+ // try rebuilding from scratch
+ log.Warn("git diff: %v", runErr)
+ if err := (*globalIndexer.Load()).Delete(ctx, repo.ID); err != nil {
+ return nil, err
+ }
+ return genesisChanges(ctx, repo, revision)
+ }
+
+ var changes internal.RepoChanges
+ var err error
+ updatedFilenames := make([]string, 0, 10)
+
+ updateChanges := func() error {
+ cmd := git.NewCommand(ctx, "ls-tree", "--full-tree", "-l").AddDynamicArguments(revision).
+ AddDashesAndList(updatedFilenames...)
+ lsTreeStdout, _, err := cmd.RunStdBytes(&git.RunOpts{Dir: repo.RepoPath()})
+ if err != nil {
+ return err
+ }
+
+ updates, err1 := parseGitLsTreeOutput(lsTreeStdout)
+ if err1 != nil {
+ return err1
+ }
+ changes.Updates = append(changes.Updates, updates...)
+ return nil
+ }
+ lines := strings.Split(stdout, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if len(line) == 0 {
+ continue
+ }
+ fields := strings.Split(line, "\t")
+ if len(fields) < 2 {
+ log.Warn("Unparsable output for diff --name-status: `%s`)", line)
+ continue
+ }
+ filename := fields[1]
+ if len(filename) == 0 {
+ continue
+ } else if filename[0] == '"' {
+ filename, err = strconv.Unquote(filename)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ switch status := fields[0][0]; status {
+ case 'M', 'A':
+ updatedFilenames = append(updatedFilenames, filename)
+ case 'D':
+ changes.RemovedFilenames = append(changes.RemovedFilenames, filename)
+ case 'R', 'C':
+ if len(fields) < 3 {
+ log.Warn("Unparsable output for diff --name-status: `%s`)", line)
+ continue
+ }
+ dest := fields[2]
+ if len(dest) == 0 {
+ log.Warn("Unparsable output for diff --name-status: `%s`)", line)
+ continue
+ }
+ if dest[0] == '"' {
+ dest, err = strconv.Unquote(dest)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if status == 'R' {
+ changes.RemovedFilenames = append(changes.RemovedFilenames, filename)
+ }
+ updatedFilenames = append(updatedFilenames, dest)
+ default:
+ log.Warn("Unrecognized status: %c (line=%s)", status, line)
+ }
+
+ // According to https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation#more-information
+ // the command line length should less than 8191 characters, assume filepath is 256, then 8191/256 = 31, so we use 30
+ if len(updatedFilenames) >= 30 {
+ if err := updateChanges(); err != nil {
+ return nil, err
+ }
+ updatedFilenames = updatedFilenames[0:0]
+ }
+ }
+
+ if len(updatedFilenames) > 0 {
+ if err := updateChanges(); err != nil {
+ return nil, err
+ }
+ }
+
+ return &changes, err
+}
diff --git a/modules/indexer/code/indexer.go b/modules/indexer/code/indexer.go
new file mode 100644
index 0000000..0a8ce27
--- /dev/null
+++ b/modules/indexer/code/indexer.go
@@ -0,0 +1,310 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package code
+
+import (
+ "context"
+ "os"
+ "runtime/pprof"
+ "slices"
+ "sync/atomic"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/indexer/code/bleve"
+ "code.gitea.io/gitea/modules/indexer/code/elasticsearch"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+var (
+ indexerQueue *queue.WorkerPoolQueue[*internal.IndexerData]
+ // globalIndexer is the global indexer, it cannot be nil.
+ // When the real indexer is not ready, it will be a dummy indexer which will return error to explain it's not ready.
+ // So it's always safe use it as *globalIndexer.Load() and call its methods.
+ globalIndexer atomic.Pointer[internal.Indexer]
+ dummyIndexer *internal.Indexer
+)
+
+func init() {
+ i := internal.NewDummyIndexer()
+ dummyIndexer = &i
+ globalIndexer.Store(dummyIndexer)
+}
+
+func index(ctx context.Context, indexer internal.Indexer, repoID int64) error {
+ repo, err := repo_model.GetRepositoryByID(ctx, repoID)
+ if repo_model.IsErrRepoNotExist(err) {
+ return indexer.Delete(ctx, repoID)
+ }
+ if err != nil {
+ return err
+ }
+
+ repoTypes := setting.Indexer.RepoIndexerRepoTypes
+
+ if len(repoTypes) == 0 {
+ repoTypes = []string{"sources"}
+ }
+
+ // skip forks from being indexed if unit is not present
+ if !slices.Contains(repoTypes, "forks") && repo.IsFork {
+ return nil
+ }
+
+ // skip mirrors from being indexed if unit is not present
+ if !slices.Contains(repoTypes, "mirrors") && repo.IsMirror {
+ return nil
+ }
+
+ // skip templates from being indexed if unit is not present
+ if !slices.Contains(repoTypes, "templates") && repo.IsTemplate {
+ return nil
+ }
+
+ // skip regular repos from being indexed if unit is not present
+ if !slices.Contains(repoTypes, "sources") && !repo.IsFork && !repo.IsMirror && !repo.IsTemplate {
+ return nil
+ }
+
+ sha, err := getDefaultBranchSha(ctx, repo)
+ if err != nil {
+ return err
+ }
+ changes, err := getRepoChanges(ctx, repo, sha)
+ if err != nil {
+ return err
+ } else if changes == nil {
+ return nil
+ }
+
+ if err := indexer.Index(ctx, repo, sha, changes); err != nil {
+ return err
+ }
+
+ return repo_model.UpdateIndexerStatus(ctx, repo, repo_model.RepoIndexerTypeCode, sha)
+}
+
+// Init initialize the repo indexer
+func Init() {
+ if !setting.Indexer.RepoIndexerEnabled {
+ (*globalIndexer.Load()).Close()
+ return
+ }
+
+ ctx, cancel, finished := process.GetManager().AddTypedContext(context.Background(), "Service: CodeIndexer", process.SystemProcessType, false)
+
+ graceful.GetManager().RunAtTerminate(func() {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ cancel()
+ log.Debug("Closing repository indexer")
+ (*globalIndexer.Load()).Close()
+ log.Info("PID: %d Repository Indexer closed", os.Getpid())
+ finished()
+ })
+
+ waitChannel := make(chan time.Duration, 1)
+
+ // Create the Queue
+ switch setting.Indexer.RepoType {
+ case "bleve", "elasticsearch":
+ handler := func(items ...*internal.IndexerData) (unhandled []*internal.IndexerData) {
+ indexer := *globalIndexer.Load()
+ // make it a process to allow for cancellation (especially during integration tests where no global shutdown happens)
+ batchCtx, _, finished := process.GetManager().AddContext(ctx, "CodeIndexer batch")
+ defer finished()
+ for _, indexerData := range items {
+ log.Trace("IndexerData Process Repo: %d", indexerData.RepoID)
+ if err := index(batchCtx, indexer, indexerData.RepoID); err != nil {
+ unhandled = append(unhandled, indexerData)
+ if !setting.IsInTesting {
+ log.Error("Codes indexer handler: index error for repo %v: %v", indexerData.RepoID, err)
+ }
+ }
+ }
+ return unhandled
+ }
+
+ indexerQueue = queue.CreateUniqueQueue(ctx, "code_indexer", handler)
+ if indexerQueue == nil {
+ log.Fatal("Unable to create codes indexer queue")
+ }
+ default:
+ log.Fatal("Unknown codes indexer type; %s", setting.Indexer.RepoType)
+ }
+
+ go func() {
+ pprof.SetGoroutineLabels(ctx)
+ start := time.Now()
+ var (
+ rIndexer internal.Indexer
+ existed bool
+ err error
+ )
+ switch setting.Indexer.RepoType {
+ case "bleve":
+ log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoPath)
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2))
+ log.Error("The indexer files are likely corrupted and may need to be deleted")
+ log.Error("You can completely remove the \"%s\" directory to make Forgejo recreate the indexes", setting.Indexer.RepoPath)
+ }
+ }()
+
+ rIndexer = bleve.NewIndexer(setting.Indexer.RepoPath)
+ existed, err = rIndexer.Init(ctx)
+ if err != nil {
+ cancel()
+ (*globalIndexer.Load()).Close()
+ close(waitChannel)
+ log.Fatal("PID: %d Unable to initialize the bleve Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err)
+ }
+ case "elasticsearch":
+ log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoConnStr)
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2))
+ log.Error("The indexer files are likely corrupted and may need to be deleted")
+ log.Error("You can completely remove the \"%s\" index to make Forgejo recreate the indexes", setting.Indexer.RepoConnStr)
+ }
+ }()
+
+ rIndexer = elasticsearch.NewIndexer(setting.Indexer.RepoConnStr, setting.Indexer.RepoIndexerName)
+ existed, err = rIndexer.Init(ctx)
+ if err != nil {
+ cancel()
+ (*globalIndexer.Load()).Close()
+ close(waitChannel)
+ log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), setting.Indexer.RepoConnStr, err)
+ }
+
+ default:
+ log.Fatal("PID: %d Unknown Indexer type: %s", os.Getpid(), setting.Indexer.RepoType)
+ }
+
+ globalIndexer.Store(&rIndexer)
+
+ // Start processing the queue
+ go graceful.GetManager().RunWithCancel(indexerQueue)
+
+ if !existed { // populate the index because it's created for the first time
+ go graceful.GetManager().RunWithShutdownContext(populateRepoIndexer)
+ }
+ select {
+ case waitChannel <- time.Since(start):
+ case <-graceful.GetManager().IsShutdown():
+ }
+
+ close(waitChannel)
+ }()
+
+ if setting.Indexer.StartupTimeout > 0 {
+ go func() {
+ pprof.SetGoroutineLabels(ctx)
+ timeout := setting.Indexer.StartupTimeout
+ if graceful.GetManager().IsChild() && setting.GracefulHammerTime > 0 {
+ timeout += setting.GracefulHammerTime
+ }
+ select {
+ case <-graceful.GetManager().IsShutdown():
+ log.Warn("Shutdown before Repository Indexer completed initialization")
+ cancel()
+ (*globalIndexer.Load()).Close()
+ case duration, ok := <-waitChannel:
+ if !ok {
+ log.Warn("Repository Indexer Initialization failed")
+ cancel()
+ (*globalIndexer.Load()).Close()
+ return
+ }
+ log.Info("Repository Indexer Initialization took %v", duration)
+ case <-time.After(timeout):
+ cancel()
+ (*globalIndexer.Load()).Close()
+ log.Fatal("Repository Indexer Initialization Timed-Out after: %v", timeout)
+ }
+ }()
+ }
+}
+
+// UpdateRepoIndexer update a repository's entries in the indexer
+func UpdateRepoIndexer(repo *repo_model.Repository) {
+ indexData := &internal.IndexerData{RepoID: repo.ID}
+ if err := indexerQueue.Push(indexData); err != nil {
+ log.Error("Update repo index data %v failed: %v", indexData, err)
+ }
+}
+
+// IsAvailable checks if issue indexer is available
+func IsAvailable(ctx context.Context) bool {
+ return (*globalIndexer.Load()).Ping(ctx) == nil
+}
+
+// populateRepoIndexer populate the repo indexer with pre-existing data. This
+// should only be run when the indexer is created for the first time.
+func populateRepoIndexer(ctx context.Context) {
+ log.Info("Populating the repo indexer with existing repositories")
+
+ exist, err := db.IsTableNotEmpty("repository")
+ if err != nil {
+ log.Fatal("System error: %v", err)
+ } else if !exist {
+ return
+ }
+
+ // if there is any existing repo indexer metadata in the DB, delete it
+ // since we are starting afresh. Also, xorm requires deletes to have a
+ // condition, and we want to delete everything, thus 1=1.
+ if err := db.DeleteAllRecords("repo_indexer_status"); err != nil {
+ log.Fatal("System error: %v", err)
+ }
+
+ var maxRepoID int64
+ if maxRepoID, err = db.GetMaxID("repository"); err != nil {
+ log.Fatal("System error: %v", err)
+ }
+
+ // start with the maximum existing repo ID and work backwards, so that we
+ // don't include repos that are created after gitea starts; such repos will
+ // already be added to the indexer, and we don't need to add them again.
+ for maxRepoID > 0 {
+ select {
+ case <-ctx.Done():
+ log.Info("Repository Indexer population shutdown before completion")
+ return
+ default:
+ }
+ ids, err := repo_model.GetUnindexedRepos(ctx, repo_model.RepoIndexerTypeCode, maxRepoID, 0, 50)
+ if err != nil {
+ log.Error("populateRepoIndexer: %v", err)
+ return
+ } else if len(ids) == 0 {
+ break
+ }
+ for _, id := range ids {
+ select {
+ case <-ctx.Done():
+ log.Info("Repository Indexer population shutdown before completion")
+ return
+ default:
+ }
+ if err := indexerQueue.Push(&internal.IndexerData{RepoID: id}); err != nil {
+ log.Error("indexerQueue.Push: %v", err)
+ return
+ }
+ maxRepoID = id - 1
+ }
+ }
+ log.Info("Done (re)populating the repo indexer with existing repositories")
+}
diff --git a/modules/indexer/code/indexer_test.go b/modules/indexer/code/indexer_test.go
new file mode 100644
index 0000000..967aad1
--- /dev/null
+++ b/modules/indexer/code/indexer_test.go
@@ -0,0 +1,145 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package code
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/indexer/code/bleve"
+ "code.gitea.io/gitea/modules/indexer/code/elasticsearch"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
+ t.Run(name, func(t *testing.T) {
+ var repoID int64 = 1
+ err := index(git.DefaultContext, indexer, repoID)
+ require.NoError(t, err)
+ keywords := []struct {
+ RepoIDs []int64
+ Keyword string
+ IDs []int64
+ Langs int
+ }{
+ {
+ RepoIDs: nil,
+ Keyword: "Description",
+ IDs: []int64{repoID},
+ Langs: 1,
+ },
+ {
+ RepoIDs: []int64{2},
+ Keyword: "Description",
+ IDs: []int64{},
+ Langs: 0,
+ },
+ {
+ RepoIDs: nil,
+ Keyword: "Description for",
+ IDs: []int64{repoID},
+ Langs: 1,
+ },
+ {
+ RepoIDs: nil,
+ Keyword: "repo1",
+ IDs: []int64{repoID},
+ Langs: 1,
+ },
+ {
+ RepoIDs: []int64{2},
+ Keyword: "repo1",
+ IDs: []int64{},
+ Langs: 0,
+ },
+ {
+ RepoIDs: nil,
+ Keyword: "non-exist",
+ IDs: []int64{},
+ Langs: 0,
+ },
+ }
+
+ for _, kw := range keywords {
+ t.Run(kw.Keyword, func(t *testing.T) {
+ total, res, langs, err := indexer.Search(context.TODO(), &internal.SearchOptions{
+ RepoIDs: kw.RepoIDs,
+ Keyword: kw.Keyword,
+ Paginator: &db.ListOptions{
+ Page: 1,
+ PageSize: 10,
+ },
+ IsKeywordFuzzy: true,
+ })
+ require.NoError(t, err)
+ assert.Len(t, kw.IDs, int(total))
+ assert.Len(t, langs, kw.Langs)
+
+ ids := make([]int64, 0, len(res))
+ for _, hit := range res {
+ ids = append(ids, hit.RepoID)
+ assert.EqualValues(t, "# repo1\n\nDescription for repo1", hit.Content)
+ }
+ assert.EqualValues(t, kw.IDs, ids)
+ })
+ }
+
+ require.NoError(t, indexer.Delete(context.Background(), repoID))
+ })
+}
+
+func TestBleveIndexAndSearch(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ dir := t.TempDir()
+
+ idx := bleve.NewIndexer(dir)
+ _, err := idx.Init(context.Background())
+ if err != nil {
+ if idx != nil {
+ idx.Close()
+ }
+ assert.FailNow(t, "Unable to create bleve indexer Error: %v", err)
+ }
+ defer idx.Close()
+
+ testIndexer("bleve", t, idx)
+}
+
+func TestESIndexAndSearch(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ u := os.Getenv("TEST_INDEXER_CODE_ES_URL")
+ if u == "" {
+ t.SkipNow()
+ return
+ }
+
+ indexer := elasticsearch.NewIndexer(u, "gitea_codes")
+ if _, err := indexer.Init(context.Background()); err != nil {
+ if indexer != nil {
+ indexer.Close()
+ }
+ assert.FailNow(t, "Unable to init ES indexer Error: %v", err)
+ }
+
+ defer indexer.Close()
+
+ testIndexer("elastic_search", t, indexer)
+}
diff --git a/modules/indexer/code/internal/indexer.go b/modules/indexer/code/internal/indexer.go
new file mode 100644
index 0000000..c259fcd
--- /dev/null
+++ b/modules/indexer/code/internal/indexer.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/indexer/internal"
+)
+
+// Indexer defines an interface to index and search code contents
+type Indexer interface {
+ internal.Indexer
+ Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error
+ Delete(ctx context.Context, repoID int64) error
+ Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error)
+}
+
+type SearchOptions struct {
+ RepoIDs []int64
+ Keyword string
+ Language string
+
+ IsKeywordFuzzy bool
+
+ db.Paginator
+}
+
+// NewDummyIndexer returns a dummy indexer
+func NewDummyIndexer() Indexer {
+ return &dummyIndexer{
+ Indexer: internal.NewDummyIndexer(),
+ }
+}
+
+type dummyIndexer struct {
+ internal.Indexer
+}
+
+func (d *dummyIndexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error {
+ return fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Delete(ctx context.Context, repoID int64) error {
+ return fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error) {
+ return 0, nil, nil, fmt.Errorf("indexer is not ready")
+}
diff --git a/modules/indexer/code/internal/model.go b/modules/indexer/code/internal/model.go
new file mode 100644
index 0000000..f75263c
--- /dev/null
+++ b/modules/indexer/code/internal/model.go
@@ -0,0 +1,44 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import "code.gitea.io/gitea/modules/timeutil"
+
+type FileUpdate struct {
+ Filename string
+ BlobSha string
+ Size int64
+ Sized bool
+}
+
+// RepoChanges changes (file additions/updates/removals) to a repo
+type RepoChanges struct {
+ Updates []FileUpdate
+ RemovedFilenames []string
+}
+
+// IndexerData represents data stored in the code indexer
+type IndexerData struct {
+ RepoID int64
+}
+
+// SearchResult result of performing a search in a repo
+type SearchResult struct {
+ RepoID int64
+ StartIndex int
+ EndIndex int
+ Filename string
+ Content string
+ CommitID string
+ UpdatedUnix timeutil.TimeStamp
+ Language string
+ Color string
+}
+
+// SearchResultLanguages result of top languages count in search results
+type SearchResultLanguages struct {
+ Language string
+ Color string
+ Count int
+}
diff --git a/modules/indexer/code/internal/util.go b/modules/indexer/code/internal/util.go
new file mode 100644
index 0000000..689c4f4
--- /dev/null
+++ b/modules/indexer/code/internal/util.go
@@ -0,0 +1,32 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "strings"
+
+ "code.gitea.io/gitea/modules/indexer/internal"
+ "code.gitea.io/gitea/modules/log"
+)
+
+func FilenameIndexerID(repoID int64, filename string) string {
+ return internal.Base36(repoID) + "_" + filename
+}
+
+func ParseIndexerID(indexerID string) (int64, string) {
+ index := strings.IndexByte(indexerID, '_')
+ if index == -1 {
+ log.Error("Unexpected ID in repo indexer: %s", indexerID)
+ }
+ repoID, _ := internal.ParseBase36(indexerID[:index])
+ return repoID, indexerID[index+1:]
+}
+
+func FilenameOfIndexerID(indexerID string) string {
+ index := strings.IndexByte(indexerID, '_')
+ if index == -1 {
+ log.Error("Unexpected ID in repo indexer: %s", indexerID)
+ }
+ return indexerID[index+1:]
+}
diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go
new file mode 100644
index 0000000..f45907a
--- /dev/null
+++ b/modules/indexer/code/search.go
@@ -0,0 +1,228 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package code
+
+import (
+ "bytes"
+ "context"
+ "html/template"
+ "strings"
+
+ "code.gitea.io/gitea/modules/highlight"
+ "code.gitea.io/gitea/modules/indexer/code/internal"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/services/gitdiff"
+)
+
+// Result a search result to display
+type Result struct {
+ RepoID int64
+ Filename string
+ CommitID string
+ UpdatedUnix timeutil.TimeStamp
+ Language string
+ Color string
+ Lines []ResultLine
+}
+
+type ResultLine struct {
+ Num int
+ FormattedContent template.HTML
+}
+
+type SearchResultLanguages = internal.SearchResultLanguages
+
+type SearchOptions = internal.SearchOptions
+
+func indices(content string, selectionStartIndex, selectionEndIndex int) (int, int) {
+ startIndex := selectionStartIndex
+ numLinesBefore := 0
+ for ; startIndex > 0; startIndex-- {
+ if content[startIndex-1] == '\n' {
+ if numLinesBefore == 1 {
+ break
+ }
+ numLinesBefore++
+ }
+ }
+
+ endIndex := selectionEndIndex
+ numLinesAfter := 0
+ for ; endIndex < len(content); endIndex++ {
+ if content[endIndex] == '\n' {
+ if numLinesAfter == 1 {
+ break
+ }
+ numLinesAfter++
+ }
+ }
+
+ return startIndex, endIndex
+}
+
+func writeStrings(buf *bytes.Buffer, strs ...string) error {
+ for _, s := range strs {
+ _, err := buf.WriteString(s)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+const (
+ highlightTagStart = "<span class=\"search-highlight\">"
+ highlightTagEnd = "</span>"
+)
+
+func HighlightSearchResultCode(filename string, lineNums []int, highlightRanges [][3]int, code string) []ResultLine {
+ hcd := gitdiff.NewHighlightCodeDiff()
+ hcd.CollectUsedRunes(code)
+ startTag, endTag := hcd.NextPlaceholder(), hcd.NextPlaceholder()
+ hcd.PlaceholderTokenMap[startTag] = highlightTagStart
+ hcd.PlaceholderTokenMap[endTag] = highlightTagEnd
+
+ // we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
+ hl, _ := highlight.Code(filename, "", code)
+ conv := hcd.ConvertToPlaceholders(string(hl))
+ convLines := strings.Split(conv, "\n")
+
+ // each highlightRange is of the form [line number, start pos, end pos]
+ for _, highlightRange := range highlightRanges {
+ ln, start, end := highlightRange[0], highlightRange[1], highlightRange[2]
+ line := convLines[ln]
+ if line == "" || len(line) <= start || len(line) < end {
+ continue
+ }
+
+ sb := strings.Builder{}
+ count := -1
+ isOpen := false
+ for _, r := range line {
+ if token, ok := hcd.PlaceholderTokenMap[r];
+ // token was not found
+ !ok ||
+ // token was marked as used
+ token == "" ||
+ // the token is not an valid html tag emitted by chroma
+ !(len(token) > 6 && (token[0:5] == "<span" || token[0:6] == "</span")) {
+ count++
+ } else if !isOpen {
+ // open the tag only after all other placeholders
+ sb.WriteRune(r)
+ continue
+ } else if isOpen && count < end {
+ // if the tag is open, but a placeholder exists in between
+ // close the tag
+ sb.WriteRune(endTag)
+ // write the placeholder
+ sb.WriteRune(r)
+ // reopen the tag
+ sb.WriteRune(startTag)
+ continue
+ }
+
+ switch count {
+ case end:
+ // if tag is not open, no need to close
+ if !isOpen {
+ break
+ }
+ sb.WriteRune(endTag)
+ isOpen = false
+ case start:
+ // if tag is open, do not open again
+ if isOpen {
+ break
+ }
+ isOpen = true
+ sb.WriteRune(startTag)
+ }
+
+ sb.WriteRune(r)
+ }
+ if isOpen {
+ sb.WriteRune(endTag)
+ }
+ convLines[ln] = sb.String()
+ }
+ conv = strings.Join(convLines, "\n")
+
+ highlightedLines := strings.Split(hcd.Recover(conv), "\n")
+ // The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
+ lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
+ for i := 0; i < len(lines); i++ {
+ lines[i].Num = lineNums[i]
+ lines[i].FormattedContent = template.HTML(highlightedLines[i])
+ }
+ return lines
+}
+
+func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Result, error) {
+ startLineNum := 1 + strings.Count(result.Content[:startIndex], "\n")
+
+ var formattedLinesBuffer bytes.Buffer
+
+ contentLines := strings.SplitAfter(result.Content[startIndex:endIndex], "\n")
+ lineNums := make([]int, 0, len(contentLines))
+ index := startIndex
+ var highlightRanges [][3]int
+ for i, line := range contentLines {
+ var err error
+ if index < result.EndIndex &&
+ result.StartIndex < index+len(line) &&
+ result.StartIndex < result.EndIndex {
+ openActiveIndex := max(result.StartIndex-index, 0)
+ closeActiveIndex := min(result.EndIndex-index, len(line))
+ highlightRanges = append(highlightRanges, [3]int{i, openActiveIndex, closeActiveIndex})
+ err = writeStrings(&formattedLinesBuffer,
+ line[:openActiveIndex],
+ line[openActiveIndex:closeActiveIndex],
+ line[closeActiveIndex:],
+ )
+ } else {
+ err = writeStrings(&formattedLinesBuffer, line)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ lineNums = append(lineNums, startLineNum+i)
+ index += len(line)
+ }
+
+ return &Result{
+ RepoID: result.RepoID,
+ Filename: result.Filename,
+ CommitID: result.CommitID,
+ UpdatedUnix: result.UpdatedUnix,
+ Language: result.Language,
+ Color: result.Color,
+ Lines: HighlightSearchResultCode(result.Filename, lineNums, highlightRanges, formattedLinesBuffer.String()),
+ }, nil
+}
+
+// PerformSearch perform a search on a repository
+// if isFuzzy is true set the Damerau-Levenshtein distance from 0 to 2
+func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, []*SearchResultLanguages, error) {
+ if opts == nil || len(opts.Keyword) == 0 {
+ return 0, nil, nil, nil
+ }
+
+ total, results, resultLanguages, err := (*globalIndexer.Load()).Search(ctx, opts)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+
+ displayResults := make([]*Result, len(results))
+
+ for i, result := range results {
+ startIndex, endIndex := indices(result.Content, result.StartIndex, result.EndIndex)
+ displayResults[i], err = searchResult(result, startIndex, endIndex)
+ if err != nil {
+ return 0, nil, nil, err
+ }
+ }
+ return int(total), displayResults, resultLanguages, nil
+}
diff --git a/modules/indexer/internal/base32.go b/modules/indexer/internal/base32.go
new file mode 100644
index 0000000..aca756c
--- /dev/null
+++ b/modules/indexer/internal/base32.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "fmt"
+ "strconv"
+)
+
+func Base36(i int64) string {
+ return strconv.FormatInt(i, 36)
+}
+
+func ParseBase36(s string) (int64, error) {
+ i, err := strconv.ParseInt(s, 36, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err)
+ }
+ return i, nil
+}
diff --git a/modules/indexer/internal/bleve/batch.go b/modules/indexer/internal/bleve/batch.go
new file mode 100644
index 0000000..ed5ef07
--- /dev/null
+++ b/modules/indexer/internal/bleve/batch.go
@@ -0,0 +1,58 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "github.com/blevesearch/bleve/v2"
+)
+
+// FlushingBatch is a batch of operations that automatically flushes to the
+// underlying index once it reaches a certain size.
+type FlushingBatch struct {
+ maxBatchSize int
+ batch *bleve.Batch
+ index bleve.Index
+}
+
+// NewFlushingBatch creates a new flushing batch for the specified index. Once
+// the number of operations in the batch reaches the specified limit, the batch
+// automatically flushes its operations to the index.
+func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch {
+ return &FlushingBatch{
+ maxBatchSize: maxBatchSize,
+ batch: index.NewBatch(),
+ index: index,
+ }
+}
+
+// Index add a new index to batch
+func (b *FlushingBatch) Index(id string, data any) error {
+ if err := b.batch.Index(id, data); err != nil {
+ return err
+ }
+ return b.flushIfFull()
+}
+
+// Delete add a delete index to batch
+func (b *FlushingBatch) Delete(id string) error {
+ b.batch.Delete(id)
+ return b.flushIfFull()
+}
+
+func (b *FlushingBatch) flushIfFull() error {
+ if b.batch.Size() < b.maxBatchSize {
+ return nil
+ }
+ return b.Flush()
+}
+
+// Flush submit the batch and create a new one
+func (b *FlushingBatch) Flush() error {
+ err := b.index.Batch(b.batch)
+ if err != nil {
+ return err
+ }
+ b.batch = b.index.NewBatch()
+ return nil
+}
diff --git a/modules/indexer/internal/bleve/indexer.go b/modules/indexer/internal/bleve/indexer.go
new file mode 100644
index 0000000..1435d2f
--- /dev/null
+++ b/modules/indexer/internal/bleve/indexer.go
@@ -0,0 +1,102 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/indexer/internal"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/mapping"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer represents a basic bleve indexer implementation
+type Indexer struct {
+ Indexer bleve.Index
+
+ indexDir string
+ version int
+ mappingGetter MappingGetter
+}
+
+type MappingGetter func() (mapping.IndexMapping, error)
+
+func NewIndexer(indexDir string, version int, mappingGetter func() (mapping.IndexMapping, error)) *Indexer {
+ return &Indexer{
+ indexDir: indexDir,
+ version: version,
+ mappingGetter: mappingGetter,
+ }
+}
+
+// Init initializes the indexer
+func (i *Indexer) Init(_ context.Context) (bool, error) {
+ if i == nil {
+ return false, fmt.Errorf("cannot init nil indexer")
+ }
+
+ if i.Indexer != nil {
+ return false, fmt.Errorf("indexer is already initialized")
+ }
+
+ indexer, version, err := openIndexer(i.indexDir, i.version)
+ if err != nil {
+ return false, err
+ }
+ if indexer != nil {
+ i.Indexer = indexer
+ return true, nil
+ }
+
+ if version != 0 {
+ log.Warn("Found older bleve index with version %d, Forgejo will remove it and rebuild", version)
+ }
+
+ indexMapping, err := i.mappingGetter()
+ if err != nil {
+ return false, err
+ }
+
+ indexer, err = bleve.New(i.indexDir, indexMapping)
+ if err != nil {
+ return false, err
+ }
+
+ if err = writeIndexMetadata(i.indexDir, &IndexMetadata{
+ Version: i.version,
+ }); err != nil {
+ return false, err
+ }
+
+ i.Indexer = indexer
+
+ return false, nil
+}
+
+// Ping checks if the indexer is available
+func (i *Indexer) Ping(_ context.Context) error {
+ if i == nil {
+ return fmt.Errorf("cannot ping nil indexer")
+ }
+ if i.Indexer == nil {
+ return fmt.Errorf("indexer is not initialized")
+ }
+ return nil
+}
+
+func (i *Indexer) Close() {
+ if i == nil || i.Indexer == nil {
+ return
+ }
+
+ if err := i.Indexer.Close(); err != nil {
+ log.Error("Failed to close bleve indexer in %q: %v", i.indexDir, err)
+ }
+ i.Indexer = nil
+}
diff --git a/modules/indexer/internal/bleve/metadata.go b/modules/indexer/internal/bleve/metadata.go
new file mode 100644
index 0000000..3c570ab
--- /dev/null
+++ b/modules/indexer/internal/bleve/metadata.go
@@ -0,0 +1,55 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Copied and modified from https://github.com/ethantkoenig/rupture (MIT License)
+
+package bleve
+
+import (
+ "os"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+const metaFilename = "rupture_meta.json"
+
+func indexMetadataPath(dir string) string {
+ return filepath.Join(dir, metaFilename)
+}
+
+// IndexMetadata contains metadata about a bleve index.
+type IndexMetadata struct {
+ // The version of the data in the index. This can be useful for tracking
+ // schema changes or data migrations.
+ Version int `json:"version"`
+}
+
+// readIndexMetadata returns the metadata for the index at the specified path.
+// If no such index metadata exists, an empty metadata and a nil error are
+// returned.
+func readIndexMetadata(path string) (*IndexMetadata, error) {
+ meta := &IndexMetadata{}
+ metaPath := indexMetadataPath(path)
+ if _, err := os.Stat(metaPath); os.IsNotExist(err) {
+ return meta, nil
+ } else if err != nil {
+ return nil, err
+ }
+
+ metaBytes, err := os.ReadFile(metaPath)
+ if err != nil {
+ return nil, err
+ }
+ return meta, json.Unmarshal(metaBytes, &meta)
+}
+
+// writeIndexMetadata writes metadata for the index at the specified path.
+func writeIndexMetadata(path string, meta *IndexMetadata) error {
+ metaBytes, err := json.Marshal(meta)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(indexMetadataPath(path), metaBytes, 0o644)
+}
diff --git a/modules/indexer/internal/bleve/metadata_test.go b/modules/indexer/internal/bleve/metadata_test.go
new file mode 100644
index 0000000..31603a9
--- /dev/null
+++ b/modules/indexer/internal/bleve/metadata_test.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Copied and modified from https://github.com/ethantkoenig/rupture (MIT License)
+
+package bleve
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMetadata(t *testing.T) {
+ dir := t.TempDir()
+
+ meta, err := readIndexMetadata(dir)
+ require.NoError(t, err)
+ assert.Equal(t, &IndexMetadata{}, meta)
+
+ meta.Version = 24
+ require.NoError(t, writeIndexMetadata(dir, meta))
+
+ meta, err = readIndexMetadata(dir)
+ require.NoError(t, err)
+ assert.EqualValues(t, 24, meta.Version)
+}
diff --git a/modules/indexer/internal/bleve/query.go b/modules/indexer/internal/bleve/query.go
new file mode 100644
index 0000000..90626da
--- /dev/null
+++ b/modules/indexer/internal/bleve/query.go
@@ -0,0 +1,56 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "code.gitea.io/gitea/modules/optional"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/search/query"
+)
+
+// NumericEqualityQuery generates a numeric equality query for the given value and field
+func NumericEqualityQuery(value int64, field string) *query.NumericRangeQuery {
+ f := float64(value)
+ tru := true // codespell-ignore
+ q := bleve.NewNumericRangeInclusiveQuery(&f, &f, &tru, &tru) // codespell-ignore
+ q.SetField(field)
+ return q
+}
+
+// MatchPhraseQuery generates a match phrase query for the given phrase, field and analyzer
+func MatchPhraseQuery(matchPhrase, field, analyzer string, fuzziness int) *query.MatchPhraseQuery {
+ q := bleve.NewMatchPhraseQuery(matchPhrase)
+ q.FieldVal = field
+ q.Analyzer = analyzer
+ q.Fuzziness = fuzziness
+ return q
+}
+
+// BoolFieldQuery generates a bool field query for the given value and field
+func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery {
+ q := bleve.NewBoolFieldQuery(value)
+ q.SetField(field)
+ return q
+}
+
+func NumericRangeInclusiveQuery(min, max optional.Option[int64], field string) *query.NumericRangeQuery {
+ var minF, maxF *float64
+ var minI, maxI *bool
+ if min.Has() {
+ minF = new(float64)
+ *minF = float64(min.Value())
+ minI = new(bool)
+ *minI = true
+ }
+ if max.Has() {
+ maxF = new(float64)
+ *maxF = float64(max.Value())
+ maxI = new(bool)
+ *maxI = true
+ }
+ q := bleve.NewNumericRangeInclusiveQuery(minF, maxF, minI, maxI)
+ q.SetField(field)
+ return q
+}
diff --git a/modules/indexer/internal/bleve/util.go b/modules/indexer/internal/bleve/util.go
new file mode 100644
index 0000000..d05b679
--- /dev/null
+++ b/modules/indexer/internal/bleve/util.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "errors"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/index/upsidedown"
+)
+
+// openIndexer open the index at the specified path, checking for metadata
+// updates and bleve version updates. If index needs to be created (or
+// re-created), returns (nil, nil)
+func openIndexer(path string, latestVersion int) (bleve.Index, int, error) {
+ _, err := os.Stat(path)
+ if err != nil && os.IsNotExist(err) {
+ return nil, 0, nil
+ } else if err != nil {
+ return nil, 0, err
+ }
+
+ metadata, err := readIndexMetadata(path)
+ if err != nil {
+ return nil, 0, err
+ }
+ if metadata.Version < latestVersion {
+ // the indexer is using a previous version, so we should delete it and
+ // re-populate
+ return nil, metadata.Version, util.RemoveAll(path)
+ }
+
+ index, err := bleve.Open(path)
+ if err != nil {
+ if errors.Is(err, upsidedown.IncompatibleVersion) {
+ log.Warn("Indexer was built with a previous version of bleve, deleting and rebuilding")
+ return nil, 0, util.RemoveAll(path)
+ }
+ return nil, 0, err
+ }
+
+ return index, 0, nil
+}
diff --git a/modules/indexer/internal/db/indexer.go b/modules/indexer/internal/db/indexer.go
new file mode 100644
index 0000000..3deec83
--- /dev/null
+++ b/modules/indexer/internal/db/indexer.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/indexer/internal"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer represents a basic db indexer implementation
+type Indexer struct{}
+
+// Init initializes the indexer
+func (i *Indexer) Init(_ context.Context) (bool, error) {
+ // Return true to indicate that the index was opened/existed.
+ // So that the indexer will not try to populate the index, the data is already there.
+ return true, nil
+}
+
+// Ping checks if the indexer is available
+func (i *Indexer) Ping(_ context.Context) error {
+ // No need to ping database to check if it is available.
+ // If the database goes down, Gitea will go down, so nobody will care if the indexer is available.
+ return nil
+}
+
+// Close closes the indexer
+func (i *Indexer) Close() {
+ // nothing to do
+}
diff --git a/modules/indexer/internal/elasticsearch/indexer.go b/modules/indexer/internal/elasticsearch/indexer.go
new file mode 100644
index 0000000..395eea3
--- /dev/null
+++ b/modules/indexer/internal/elasticsearch/indexer.go
@@ -0,0 +1,93 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/indexer/internal"
+
+ "github.com/olivere/elastic/v7"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer represents a basic elasticsearch indexer implementation
+type Indexer struct {
+ Client *elastic.Client
+
+ url string
+ indexName string
+ version int
+ mapping string
+}
+
+func NewIndexer(url, indexName string, version int, mapping string) *Indexer {
+ return &Indexer{
+ url: url,
+ indexName: indexName,
+ version: version,
+ mapping: mapping,
+ }
+}
+
+// Init initializes the indexer
+func (i *Indexer) Init(ctx context.Context) (bool, error) {
+ if i == nil {
+ return false, fmt.Errorf("cannot init nil indexer")
+ }
+ if i.Client != nil {
+ return false, fmt.Errorf("indexer is already initialized")
+ }
+
+ client, err := i.initClient()
+ if err != nil {
+ return false, err
+ }
+ i.Client = client
+
+ exists, err := i.Client.IndexExists(i.VersionedIndexName()).Do(ctx)
+ if err != nil {
+ return false, err
+ }
+ if exists {
+ return true, nil
+ }
+
+ if err := i.createIndex(ctx); err != nil {
+ return false, err
+ }
+
+ return exists, nil
+}
+
+// Ping checks if the indexer is available
+func (i *Indexer) Ping(ctx context.Context) error {
+ if i == nil {
+ return fmt.Errorf("cannot ping nil indexer")
+ }
+ if i.Client == nil {
+ return fmt.Errorf("indexer is not initialized")
+ }
+
+ resp, err := i.Client.ClusterHealth().Do(ctx)
+ if err != nil {
+ return err
+ }
+ if resp.Status != "green" && resp.Status != "yellow" {
+ // It's healthy if the status is green, and it's available if the status is yellow,
+ // see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html
+ return fmt.Errorf("status of elasticsearch cluster is %s", resp.Status)
+ }
+ return nil
+}
+
+// Close closes the indexer
+func (i *Indexer) Close() {
+ if i == nil {
+ return
+ }
+ i.Client = nil
+}
diff --git a/modules/indexer/internal/elasticsearch/util.go b/modules/indexer/internal/elasticsearch/util.go
new file mode 100644
index 0000000..18cb152
--- /dev/null
+++ b/modules/indexer/internal/elasticsearch/util.go
@@ -0,0 +1,68 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/olivere/elastic/v7"
+)
+
+// VersionedIndexName returns the full index name with version
+func (i *Indexer) VersionedIndexName() string {
+ return versionedIndexName(i.indexName, i.version)
+}
+
+func versionedIndexName(indexName string, version int) string {
+ if version == 0 {
+ // Old index name without version
+ return indexName
+ }
+ return fmt.Sprintf("%s.v%d", indexName, version)
+}
+
+func (i *Indexer) createIndex(ctx context.Context) error {
+ createIndex, err := i.Client.CreateIndex(i.VersionedIndexName()).BodyString(i.mapping).Do(ctx)
+ if err != nil {
+ return err
+ }
+ if !createIndex.Acknowledged {
+ return fmt.Errorf("create index %s with %s failed", i.VersionedIndexName(), i.mapping)
+ }
+
+ i.checkOldIndexes(ctx)
+
+ return nil
+}
+
+func (i *Indexer) initClient() (*elastic.Client, error) {
+ opts := []elastic.ClientOptionFunc{
+ elastic.SetURL(i.url),
+ elastic.SetSniff(false),
+ elastic.SetHealthcheckInterval(10 * time.Second),
+ elastic.SetGzip(false),
+ }
+
+ logger := log.GetLogger(log.DEFAULT)
+
+ opts = append(opts, elastic.SetTraceLog(&log.PrintfLogger{Logf: logger.Trace}))
+ opts = append(opts, elastic.SetInfoLog(&log.PrintfLogger{Logf: logger.Info}))
+ opts = append(opts, elastic.SetErrorLog(&log.PrintfLogger{Logf: logger.Error}))
+
+ return elastic.NewClient(opts...)
+}
+
+func (i *Indexer) checkOldIndexes(ctx context.Context) {
+ for v := 0; v < i.version; v++ {
+ indexName := versionedIndexName(i.indexName, v)
+ exists, err := i.Client.IndexExists(indexName).Do(ctx)
+ if err == nil && exists {
+ log.Warn("Found older elasticsearch index named %q, Forgejo will keep the old NOT DELETED. You can delete the old version after the upgrade succeed.", indexName)
+ }
+ }
+}
diff --git a/modules/indexer/internal/indexer.go b/modules/indexer/internal/indexer.go
new file mode 100644
index 0000000..c7f356d
--- /dev/null
+++ b/modules/indexer/internal/indexer.go
@@ -0,0 +1,37 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "context"
+ "fmt"
+)
+
+// Indexer defines an basic indexer interface
+type Indexer interface {
+ // Init initializes the indexer
+ // returns true if the index was opened/existed (with data populated), false if it was created/not-existed (with no data)
+ Init(ctx context.Context) (bool, error)
+ // Ping checks if the indexer is available
+ Ping(ctx context.Context) error
+ // Close closes the indexer
+ Close()
+}
+
+// NewDummyIndexer returns a dummy indexer
+func NewDummyIndexer() Indexer {
+ return &dummyIndexer{}
+}
+
+type dummyIndexer struct{}
+
+func (d *dummyIndexer) Init(ctx context.Context) (bool, error) {
+ return false, fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Ping(ctx context.Context) error {
+ return fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Close() {}
diff --git a/modules/indexer/internal/meilisearch/filter.go b/modules/indexer/internal/meilisearch/filter.go
new file mode 100644
index 0000000..593177f
--- /dev/null
+++ b/modules/indexer/internal/meilisearch/filter.go
@@ -0,0 +1,119 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package meilisearch
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Filter represents a filter for meilisearch queries.
+// It's just a simple wrapper around a string.
+// DO NOT assume that it is a complete implementation.
+type Filter interface {
+ Statement() string
+}
+
+type FilterAnd struct {
+ filters []Filter
+}
+
+func (f *FilterAnd) Statement() string {
+ var statements []string
+ for _, filter := range f.filters {
+ if s := filter.Statement(); s != "" {
+ statements = append(statements, fmt.Sprintf("(%s)", s))
+ }
+ }
+ return strings.Join(statements, " AND ")
+}
+
+func (f *FilterAnd) And(filter Filter) *FilterAnd {
+ f.filters = append(f.filters, filter)
+ return f
+}
+
+type FilterOr struct {
+ filters []Filter
+}
+
+func (f *FilterOr) Statement() string {
+ var statements []string
+ for _, filter := range f.filters {
+ if s := filter.Statement(); s != "" {
+ statements = append(statements, fmt.Sprintf("(%s)", s))
+ }
+ }
+ return strings.Join(statements, " OR ")
+}
+
+func (f *FilterOr) Or(filter Filter) *FilterOr {
+ f.filters = append(f.filters, filter)
+ return f
+}
+
+type FilterIn string
+
+// NewFilterIn creates a new FilterIn.
+// It supports int64 only, to avoid extra works to handle strings with special characters.
+func NewFilterIn[T int64](field string, values ...T) FilterIn {
+ if len(values) == 0 {
+ return ""
+ }
+ vs := make([]string, len(values))
+ for i, v := range values {
+ vs[i] = fmt.Sprintf("%v", v)
+ }
+ return FilterIn(fmt.Sprintf("%s IN [%v]", field, strings.Join(vs, ", ")))
+}
+
+func (f FilterIn) Statement() string {
+ return string(f)
+}
+
+type FilterEq string
+
+// NewFilterEq creates a new FilterEq.
+// It supports int64 and bool only, to avoid extra works to handle strings with special characters.
+func NewFilterEq[T bool | int64](field string, value T) FilterEq {
+ return FilterEq(fmt.Sprintf("%s = %v", field, value))
+}
+
+func (f FilterEq) Statement() string {
+ return string(f)
+}
+
+type FilterNot string
+
+func NewFilterNot(filter Filter) FilterNot {
+ return FilterNot(fmt.Sprintf("NOT (%s)", filter.Statement()))
+}
+
+func (f FilterNot) Statement() string {
+ return string(f)
+}
+
+type FilterGte string
+
+// NewFilterGte creates a new FilterGte.
+// It supports int64 only, to avoid extra works to handle strings with special characters.
+func NewFilterGte[T int64](field string, value T) FilterGte {
+ return FilterGte(fmt.Sprintf("%s >= %v", field, value))
+}
+
+func (f FilterGte) Statement() string {
+ return string(f)
+}
+
+type FilterLte string
+
+// NewFilterLte creates a new FilterLte.
+// It supports int64 only, to avoid extra works to handle strings with special characters.
+func NewFilterLte[T int64](field string, value T) FilterLte {
+ return FilterLte(fmt.Sprintf("%s <= %v", field, value))
+}
+
+func (f FilterLte) Statement() string {
+ return string(f)
+}
diff --git a/modules/indexer/internal/meilisearch/indexer.go b/modules/indexer/internal/meilisearch/indexer.go
new file mode 100644
index 0000000..feac1d0
--- /dev/null
+++ b/modules/indexer/internal/meilisearch/indexer.go
@@ -0,0 +1,88 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package meilisearch
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/meilisearch/meilisearch-go"
+)
+
+// Indexer represents a basic meilisearch indexer implementation
+type Indexer struct {
+ Client meilisearch.ServiceManager
+
+ url, apiKey string
+ indexName string
+ version int
+ settings *meilisearch.Settings
+}
+
+func NewIndexer(url, apiKey, indexName string, version int, settings *meilisearch.Settings) *Indexer {
+ return &Indexer{
+ url: url,
+ apiKey: apiKey,
+ indexName: indexName,
+ version: version,
+ settings: settings,
+ }
+}
+
+// Init initializes the indexer
+func (i *Indexer) Init(_ context.Context) (bool, error) {
+ if i == nil {
+ return false, fmt.Errorf("cannot init nil indexer")
+ }
+
+ if i.Client != nil {
+ return false, fmt.Errorf("indexer is already initialized")
+ }
+
+ i.Client = meilisearch.New(i.url, meilisearch.WithAPIKey(i.apiKey))
+
+ _, err := i.Client.GetIndex(i.VersionedIndexName())
+ if err == nil {
+ return true, nil
+ }
+ _, err = i.Client.CreateIndex(&meilisearch.IndexConfig{
+ Uid: i.VersionedIndexName(),
+ PrimaryKey: "id",
+ })
+ if err != nil {
+ return false, err
+ }
+
+ i.checkOldIndexes()
+
+ _, err = i.Client.Index(i.VersionedIndexName()).UpdateSettings(i.settings)
+ return false, err
+}
+
+// Ping checks if the indexer is available
+func (i *Indexer) Ping(ctx context.Context) error {
+ if i == nil {
+ return fmt.Errorf("cannot ping nil indexer")
+ }
+ if i.Client == nil {
+ return fmt.Errorf("indexer is not initialized")
+ }
+ resp, err := i.Client.Health()
+ if err != nil {
+ return err
+ }
+ if resp.Status != "available" {
+ // See https://docs.meilisearch.com/reference/api/health.html#status
+ return fmt.Errorf("status of meilisearch is not available: %s", resp.Status)
+ }
+ return nil
+}
+
+// Close closes the indexer
+func (i *Indexer) Close() {
+ if i == nil {
+ return
+ }
+ i.Client = nil
+}
diff --git a/modules/indexer/internal/meilisearch/util.go b/modules/indexer/internal/meilisearch/util.go
new file mode 100644
index 0000000..845bdb6
--- /dev/null
+++ b/modules/indexer/internal/meilisearch/util.go
@@ -0,0 +1,38 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package meilisearch
+
+import (
+ "fmt"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// VersionedIndexName returns the full index name with version
+func (i *Indexer) VersionedIndexName() string {
+ return versionedIndexName(i.indexName, i.version)
+}
+
+func versionedIndexName(indexName string, version int) string {
+ if version == 0 {
+ // Old index name without version
+ return indexName
+ }
+
+ // The format of the index name is <index_name>_v<version>, not <index_name>.v<version> like elasticsearch.
+ // Because meilisearch does not support "." in index name, it should contain only alphanumeric characters, hyphens (-) and underscores (_).
+ // See https://www.meilisearch.com/docs/learn/core_concepts/indexes#index-uid
+
+ return fmt.Sprintf("%s_v%d", indexName, version)
+}
+
+func (i *Indexer) checkOldIndexes() {
+ for v := 0; v < i.version; v++ {
+ indexName := versionedIndexName(i.indexName, v)
+ _, err := i.Client.GetIndex(indexName)
+ if err == nil {
+ log.Warn("Found older meilisearch index named %q, Forgejo will keep the old NOT DELETED. You can delete the old version after the upgrade succeed.", indexName)
+ }
+ }
+}
diff --git a/modules/indexer/internal/paginator.go b/modules/indexer/internal/paginator.go
new file mode 100644
index 0000000..ee204bf
--- /dev/null
+++ b/modules/indexer/internal/paginator.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "math"
+
+ "code.gitea.io/gitea/models/db"
+)
+
+// ParsePaginator parses a db.Paginator into a skip and limit
+func ParsePaginator(paginator *db.ListOptions, max ...int) (int, int) {
+ // Use a very large number to indicate no limit
+ unlimited := math.MaxInt32
+ if len(max) > 0 {
+ // Some indexer engines have a limit on the page size, respect that
+ unlimited = max[0]
+ }
+
+ if paginator == nil || paginator.IsListAll() {
+ // It shouldn't happen. In actual usage scenarios, there should not be requests to search all.
+ // But if it does happen, respect it and return "unlimited".
+ // And it's also useful for testing.
+ return 0, unlimited
+ }
+
+ if paginator.PageSize == 0 {
+ // Do not return any results when searching, it's used to get the total count only.
+ return 0, 0
+ }
+
+ return paginator.GetSkipTake()
+}
diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go
new file mode 100644
index 0000000..b20fcc6
--- /dev/null
+++ b/modules/indexer/issues/bleve/bleve.go
@@ -0,0 +1,300 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "context"
+
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
+ "github.com/blevesearch/bleve/v2/analysis/token/camelcase"
+ "github.com/blevesearch/bleve/v2/analysis/token/lowercase"
+ "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
+ "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
+ "github.com/blevesearch/bleve/v2/mapping"
+ "github.com/blevesearch/bleve/v2/search/query"
+)
+
+const (
+ issueIndexerAnalyzer = "issueIndexer"
+ issueIndexerDocType = "issueIndexerDocType"
+ issueIndexerLatestVersion = 4
+)
+
+const unicodeNormalizeName = "unicodeNormalize"
+
+func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error {
+ return m.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
+ "type": unicodenorm.Name,
+ "form": unicodenorm.NFC,
+ })
+}
+
+const (
+ maxBatchSize = 16
+ // fuzzyDenominator determines the levenshtein distance per each character of a keyword
+ fuzzyDenominator = 4
+ // see https://github.com/blevesearch/bleve/issues/1563#issuecomment-786822311
+ maxFuzziness = 2
+)
+
+// IndexerData an update to the issue indexer
+type IndexerData internal.IndexerData
+
+// Type returns the document type, for bleve's mapping.Classifier interface.
+func (i *IndexerData) Type() string {
+ return issueIndexerDocType
+}
+
+// generateIssueIndexMapping generates the bleve index mapping for issues
+func generateIssueIndexMapping() (mapping.IndexMapping, error) {
+ mapping := bleve.NewIndexMapping()
+ docMapping := bleve.NewDocumentMapping()
+
+ numericFieldMapping := bleve.NewNumericFieldMapping()
+ numericFieldMapping.Store = false
+ numericFieldMapping.IncludeInAll = false
+ docMapping.AddFieldMappingsAt("repo_id", numericFieldMapping)
+
+ textFieldMapping := bleve.NewTextFieldMapping()
+ textFieldMapping.Store = false
+ textFieldMapping.IncludeInAll = false
+
+ boolFieldMapping := bleve.NewBooleanFieldMapping()
+ boolFieldMapping.Store = false
+ boolFieldMapping.IncludeInAll = false
+
+ numberFieldMapping := bleve.NewNumericFieldMapping()
+ numberFieldMapping.Store = false
+ numberFieldMapping.IncludeInAll = false
+
+ docMapping.AddFieldMappingsAt("is_public", boolFieldMapping)
+
+ docMapping.AddFieldMappingsAt("title", textFieldMapping)
+ docMapping.AddFieldMappingsAt("content", textFieldMapping)
+ docMapping.AddFieldMappingsAt("comments", textFieldMapping)
+
+ docMapping.AddFieldMappingsAt("is_pull", boolFieldMapping)
+ docMapping.AddFieldMappingsAt("is_closed", boolFieldMapping)
+ docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
+ docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("reviewed_ids", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("review_requested_ids", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("subscriber_ids", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("updated_unix", numberFieldMapping)
+
+ docMapping.AddFieldMappingsAt("created_unix", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("deadline_unix", numberFieldMapping)
+ docMapping.AddFieldMappingsAt("comment_count", numberFieldMapping)
+
+ if err := addUnicodeNormalizeTokenFilter(mapping); err != nil {
+ return nil, err
+ } else if err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{
+ "type": custom.Name,
+ "char_filters": []string{},
+ "tokenizer": unicode.Name,
+ "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
+ }); err != nil {
+ return nil, err
+ }
+
+ mapping.DefaultAnalyzer = issueIndexerAnalyzer
+ mapping.AddDocumentMapping(issueIndexerDocType, docMapping)
+ mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
+ mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() // disable default mapping, avoid indexing unexpected structs
+
+ return mapping, nil
+}
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer implements Indexer interface
+type Indexer struct {
+ inner *inner_bleve.Indexer
+ indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much
+}
+
+// NewIndexer creates a new bleve local indexer
+func NewIndexer(indexDir string) *Indexer {
+ inner := inner_bleve.NewIndexer(indexDir, issueIndexerLatestVersion, generateIssueIndexMapping)
+ return &Indexer{
+ Indexer: inner,
+ inner: inner,
+ }
+}
+
+// Index will save the index data
+func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error {
+ batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize)
+ for _, issue := range issues {
+ if err := batch.Index(indexer_internal.Base36(issue.ID), (*IndexerData)(issue)); err != nil {
+ return err
+ }
+ }
+ return batch.Flush()
+}
+
+// Delete deletes indexes by ids
+func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
+ batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize)
+ for _, id := range ids {
+ if err := batch.Delete(indexer_internal.Base36(id)); err != nil {
+ return err
+ }
+ }
+ return batch.Flush()
+}
+
+// Search searches for issues by given conditions.
+// Returns the matching issue IDs
+func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
+ var queries []query.Query
+
+ if options.Keyword != "" {
+ fuzziness := 0
+ if options.IsFuzzyKeyword {
+ fuzziness = min(maxFuzziness, len(options.Keyword)/fuzzyDenominator)
+ }
+
+ queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
+ inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer, fuzziness),
+ inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer, fuzziness),
+ inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer, fuzziness),
+ }...))
+ }
+
+ if len(options.RepoIDs) > 0 || options.AllPublic {
+ var repoQueries []query.Query
+ for _, repoID := range options.RepoIDs {
+ repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "repo_id"))
+ }
+ if options.AllPublic {
+ repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public"))
+ }
+ queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
+ }
+
+ if options.IsPull.Has() {
+ queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
+ }
+ if options.IsClosed.Has() {
+ queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed"))
+ }
+
+ if options.NoLabelOnly {
+ queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label"))
+ } else {
+ if len(options.IncludedLabelIDs) > 0 {
+ var includeQueries []query.Query
+ for _, labelID := range options.IncludedLabelIDs {
+ includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
+ }
+ queries = append(queries, bleve.NewConjunctionQuery(includeQueries...))
+ } else if len(options.IncludedAnyLabelIDs) > 0 {
+ var includeQueries []query.Query
+ for _, labelID := range options.IncludedAnyLabelIDs {
+ includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
+ }
+ queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...))
+ }
+ if len(options.ExcludedLabelIDs) > 0 {
+ var excludeQueries []query.Query
+ for _, labelID := range options.ExcludedLabelIDs {
+ q := bleve.NewBooleanQuery()
+ q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
+ excludeQueries = append(excludeQueries, q)
+ }
+ queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...))
+ }
+ }
+
+ if len(options.MilestoneIDs) > 0 {
+ var milestoneQueries []query.Query
+ for _, milestoneID := range options.MilestoneIDs {
+ milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id"))
+ }
+ queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
+ }
+
+ if options.ProjectID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
+ }
+ if options.ProjectColumnID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
+ }
+
+ if options.PosterID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id"))
+ }
+
+ if options.AssigneeID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id"))
+ }
+
+ if options.MentionID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.MentionID.Value(), "mention_ids"))
+ }
+
+ if options.ReviewedID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewedID.Value(), "reviewed_ids"))
+ }
+ if options.ReviewRequestedID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewRequestedID.Value(), "review_requested_ids"))
+ }
+
+ if options.SubscriberID.Has() {
+ queries = append(queries, inner_bleve.NumericEqualityQuery(options.SubscriberID.Value(), "subscriber_ids"))
+ }
+
+ if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
+ queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(
+ options.UpdatedAfterUnix,
+ options.UpdatedBeforeUnix,
+ "updated_unix"))
+ }
+
+ var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
+ if len(queries) == 0 {
+ indexerQuery = bleve.NewMatchAllQuery()
+ }
+
+ skip, limit := indexer_internal.ParsePaginator(options.Paginator)
+ search := bleve.NewSearchRequestOptions(indexerQuery, limit, skip, false)
+
+ if options.SortBy == "" {
+ options.SortBy = internal.SortByCreatedAsc
+ }
+
+ search.SortBy([]string{string(options.SortBy), "-_id"})
+
+ result, err := b.inner.Indexer.SearchInContext(ctx, search)
+ if err != nil {
+ return nil, err
+ }
+
+ ret := &internal.SearchResult{
+ Total: int64(result.Total),
+ Hits: make([]internal.Match, 0, len(result.Hits)),
+ }
+ for _, hit := range result.Hits {
+ id, err := indexer_internal.ParseBase36(hit.ID)
+ if err != nil {
+ return nil, err
+ }
+ ret.Hits = append(ret.Hits, internal.Match{
+ ID: id,
+ })
+ }
+ return ret, nil
+}
diff --git a/modules/indexer/issues/bleve/bleve_test.go b/modules/indexer/issues/bleve/bleve_test.go
new file mode 100644
index 0000000..908514a
--- /dev/null
+++ b/modules/indexer/issues/bleve/bleve_test.go
@@ -0,0 +1,18 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package bleve
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/indexer/issues/internal/tests"
+)
+
+func TestBleveIndexer(t *testing.T) {
+ dir := t.TempDir()
+ indexer := NewIndexer(dir)
+ defer indexer.Close()
+
+ tests.TestIndexer(t, indexer)
+}
diff --git a/modules/indexer/issues/db/db.go b/modules/indexer/issues/db/db.go
new file mode 100644
index 0000000..05ec548
--- /dev/null
+++ b/modules/indexer/issues/db/db.go
@@ -0,0 +1,107 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_db "code.gitea.io/gitea/modules/indexer/internal/db"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+
+ "xorm.io/builder"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer implements Indexer interface to use database's like search
+type Indexer struct {
+ indexer_internal.Indexer
+}
+
+func NewIndexer() *Indexer {
+ return &Indexer{
+ Indexer: &inner_db.Indexer{},
+ }
+}
+
+// Index dummy function
+func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
+ return nil
+}
+
+// Delete dummy function
+func (i *Indexer) Delete(_ context.Context, _ ...int64) error {
+ return nil
+}
+
+// Search searches for issues
+func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
+ // FIXME: I tried to avoid importing models here, but it seems to be impossible.
+ // We can provide a function to register the search function, so models/issues can register it.
+ // So models/issues will import modules/indexer/issues, it's OK because it's by design.
+ // But modules/indexer/issues has already imported models/issues to do UpdateRepoIndexer and UpdateIssueIndexer.
+ // And to avoid circular import, we have to move the functions to another package.
+ // I believe it should be services/indexer, sounds great!
+ // But the two functions are used in modules/notification/indexer, that means we will import services/indexer in modules/notification/indexer.
+ // So that's the root problem:
+ // The notification is defined in modules, but it's using lots of things should be in services.
+
+ cond := builder.NewCond()
+
+ if options.Keyword != "" {
+ repoCond := builder.In("repo_id", options.RepoIDs)
+ if len(options.RepoIDs) == 1 {
+ repoCond = builder.Eq{"repo_id": options.RepoIDs[0]}
+ }
+ subQuery := builder.Select("id").From("issue").Where(repoCond)
+
+ cond = builder.Or(
+ db.BuildCaseInsensitiveLike("issue.name", options.Keyword),
+ db.BuildCaseInsensitiveLike("issue.content", options.Keyword),
+ builder.In("issue.id", builder.Select("issue_id").
+ From("comment").
+ Where(builder.And(
+ builder.Eq{"type": issue_model.CommentTypeComment},
+ builder.In("issue_id", subQuery),
+ db.BuildCaseInsensitiveLike("content", options.Keyword),
+ )),
+ ),
+ )
+ }
+
+ opt, err := ToDBOptions(ctx, options)
+ if err != nil {
+ return nil, err
+ }
+
+ // If pagesize == 0, return total count only. It's a special case for search count.
+ if options.Paginator != nil && options.Paginator.PageSize == 0 {
+ total, err := issue_model.CountIssues(ctx, opt, cond)
+ if err != nil {
+ return nil, err
+ }
+ return &internal.SearchResult{
+ Total: total,
+ }, nil
+ }
+
+ ids, total, err := issue_model.IssueIDs(ctx, opt, cond)
+ if err != nil {
+ return nil, err
+ }
+
+ hits := make([]internal.Match, 0, len(ids))
+ for _, id := range ids {
+ hits = append(hits, internal.Match{
+ ID: id,
+ })
+ }
+ return &internal.SearchResult{
+ Total: total,
+ Hits: hits,
+ }, nil
+}
diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go
new file mode 100644
index 0000000..875a4ca
--- /dev/null
+++ b/modules/indexer/issues/db/options.go
@@ -0,0 +1,112 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/optional"
+)
+
+func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
+ var sortType string
+ switch options.SortBy {
+ case internal.SortByCreatedAsc:
+ sortType = "oldest"
+ case internal.SortByUpdatedAsc:
+ sortType = "leastupdate"
+ case internal.SortByCommentsAsc:
+ sortType = "leastcomment"
+ case internal.SortByDeadlineDesc:
+ sortType = "farduedate"
+ case internal.SortByCreatedDesc:
+ sortType = "newest"
+ case internal.SortByUpdatedDesc:
+ sortType = "recentupdate"
+ case internal.SortByCommentsDesc:
+ sortType = "mostcomment"
+ case internal.SortByDeadlineAsc:
+ sortType = "nearduedate"
+ default:
+ sortType = "newest"
+ }
+
+ // See the comment of issues_model.SearchOptions for the reason why we need to convert
+ convertID := func(id optional.Option[int64]) int64 {
+ if !id.Has() {
+ return 0
+ }
+ value := id.Value()
+ if value == 0 {
+ return db.NoConditionID
+ }
+ return value
+ }
+
+ opts := &issue_model.IssuesOptions{
+ Paginator: options.Paginator,
+ RepoIDs: options.RepoIDs,
+ AllPublic: options.AllPublic,
+ RepoCond: nil,
+ AssigneeID: convertID(options.AssigneeID),
+ PosterID: convertID(options.PosterID),
+ MentionedID: convertID(options.MentionID),
+ ReviewRequestedID: convertID(options.ReviewRequestedID),
+ ReviewedID: convertID(options.ReviewedID),
+ SubscriberID: convertID(options.SubscriberID),
+ ProjectID: convertID(options.ProjectID),
+ ProjectColumnID: convertID(options.ProjectColumnID),
+ IsClosed: options.IsClosed,
+ IsPull: options.IsPull,
+ IncludedLabelNames: nil,
+ ExcludedLabelNames: nil,
+ IncludeMilestones: nil,
+ SortType: sortType,
+ IssueIDs: nil,
+ UpdatedAfterUnix: options.UpdatedAfterUnix.Value(),
+ UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(),
+ PriorityRepoID: 0,
+ IsArchived: optional.None[bool](),
+ Org: nil,
+ Team: nil,
+ User: nil,
+ }
+
+ if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
+ opts.MilestoneIDs = []int64{db.NoConditionID}
+ } else {
+ opts.MilestoneIDs = options.MilestoneIDs
+ }
+
+ if options.NoLabelOnly {
+ opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID
+ } else {
+ opts.LabelIDs = make([]int64, 0, len(options.IncludedLabelIDs)+len(options.ExcludedLabelIDs))
+ opts.LabelIDs = append(opts.LabelIDs, options.IncludedLabelIDs...)
+ for _, id := range options.ExcludedLabelIDs {
+ opts.LabelIDs = append(opts.LabelIDs, -id)
+ }
+
+ if len(options.IncludedLabelIDs) == 0 && len(options.IncludedAnyLabelIDs) > 0 {
+ labels, err := issue_model.GetLabelsByIDs(ctx, options.IncludedAnyLabelIDs, "name")
+ if err != nil {
+ return nil, fmt.Errorf("GetLabelsByIDs: %v", err)
+ }
+ set := container.Set[string]{}
+ for _, label := range labels {
+ if !set.Contains(label.Name) {
+ set.Add(label.Name)
+ opts.IncludedLabelNames = append(opts.IncludedLabelNames, label.Name)
+ }
+ }
+ }
+ }
+
+ return opts, nil
+}
diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go
new file mode 100644
index 0000000..c1f454e
--- /dev/null
+++ b/modules/indexer/issues/dboptions.go
@@ -0,0 +1,105 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issues
+
+import (
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/optional"
+)
+
+func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
+ searchOpt := &SearchOptions{
+ Keyword: keyword,
+ RepoIDs: opts.RepoIDs,
+ AllPublic: opts.AllPublic,
+ IsPull: opts.IsPull,
+ IsClosed: opts.IsClosed,
+ }
+
+ if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 {
+ searchOpt.NoLabelOnly = true
+ } else {
+ for _, labelID := range opts.LabelIDs {
+ if labelID > 0 {
+ searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
+ } else {
+ searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
+ }
+ }
+ // opts.IncludedLabelNames and opts.ExcludedLabelNames are not supported here.
+ // It's not a TO DO, it's just unnecessary.
+ }
+
+ if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID {
+ searchOpt.MilestoneIDs = []int64{0}
+ } else {
+ searchOpt.MilestoneIDs = opts.MilestoneIDs
+ }
+
+ if opts.ProjectID > 0 {
+ searchOpt.ProjectID = optional.Some(opts.ProjectID)
+ } else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places
+ searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
+ }
+
+ if opts.AssigneeID > 0 {
+ searchOpt.AssigneeID = optional.Some(opts.AssigneeID)
+ } else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places
+ searchOpt.AssigneeID = optional.Some[int64](0)
+ }
+
+ // See the comment of issues_model.SearchOptions for the reason why we need to convert
+ convertID := func(id int64) optional.Option[int64] {
+ if id > 0 {
+ return optional.Some(id)
+ }
+ if id == db.NoConditionID {
+ return optional.None[int64]()
+ }
+ return nil
+ }
+
+ searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
+ searchOpt.PosterID = convertID(opts.PosterID)
+ searchOpt.MentionID = convertID(opts.MentionedID)
+ searchOpt.ReviewedID = convertID(opts.ReviewedID)
+ searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID)
+ searchOpt.SubscriberID = convertID(opts.SubscriberID)
+
+ if opts.UpdatedAfterUnix > 0 {
+ searchOpt.UpdatedAfterUnix = optional.Some(opts.UpdatedAfterUnix)
+ }
+ if opts.UpdatedBeforeUnix > 0 {
+ searchOpt.UpdatedBeforeUnix = optional.Some(opts.UpdatedBeforeUnix)
+ }
+
+ searchOpt.Paginator = opts.Paginator
+
+ switch opts.SortType {
+ case "", "latest":
+ searchOpt.SortBy = SortByCreatedDesc
+ case "oldest":
+ searchOpt.SortBy = SortByCreatedAsc
+ case "recentupdate":
+ searchOpt.SortBy = SortByUpdatedDesc
+ case "leastupdate":
+ searchOpt.SortBy = SortByUpdatedAsc
+ case "mostcomment":
+ searchOpt.SortBy = SortByCommentsDesc
+ case "leastcomment":
+ searchOpt.SortBy = SortByCommentsAsc
+ case "nearduedate":
+ searchOpt.SortBy = SortByDeadlineAsc
+ case "farduedate":
+ searchOpt.SortBy = SortByDeadlineDesc
+ case "priority", "priorityrepo", "project-column-sorting":
+ // Unsupported sort type for search
+ fallthrough
+ default:
+ searchOpt.SortBy = SortByUpdatedDesc
+ }
+
+ return searchOpt
+}
diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go
new file mode 100644
index 0000000..42e709a
--- /dev/null
+++ b/modules/indexer/issues/elasticsearch/elasticsearch.go
@@ -0,0 +1,290 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/graceful"
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+
+ "github.com/olivere/elastic/v7"
+)
+
+const (
+ issueIndexerLatestVersion = 1
+ // multi-match-types, currently only 2 types are used
+ // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
+ esMultiMatchTypeBestFields = "best_fields"
+ esMultiMatchTypePhrasePrefix = "phrase_prefix"
+)
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer implements Indexer interface
+type Indexer struct {
+ inner *inner_elasticsearch.Indexer
+ indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
+}
+
+// NewIndexer creates a new elasticsearch indexer
+func NewIndexer(url, indexerName string) *Indexer {
+ inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)
+ indexer := &Indexer{
+ inner: inner,
+ Indexer: inner,
+ }
+ return indexer
+}
+
+const (
+ defaultMapping = `
+{
+ "mappings": {
+ "properties": {
+ "id": { "type": "long", "index": true },
+ "repo_id": { "type": "long", "index": true },
+ "is_public": { "type": "boolean", "index": true },
+
+ "title": { "type": "text", "index": true },
+ "content": { "type": "text", "index": true },
+ "comments": { "type" : "text", "index": true },
+
+ "is_pull": { "type": "boolean", "index": true },
+ "is_closed": { "type": "boolean", "index": true },
+ "label_ids": { "type": "long", "index": true },
+ "no_label": { "type": "boolean", "index": true },
+ "milestone_id": { "type": "long", "index": true },
+ "project_id": { "type": "long", "index": true },
+ "project_board_id": { "type": "long", "index": true },
+ "poster_id": { "type": "long", "index": true },
+ "assignee_id": { "type": "long", "index": true },
+ "mention_ids": { "type": "long", "index": true },
+ "reviewed_ids": { "type": "long", "index": true },
+ "review_requested_ids": { "type": "long", "index": true },
+ "subscriber_ids": { "type": "long", "index": true },
+ "updated_unix": { "type": "long", "index": true },
+
+ "created_unix": { "type": "long", "index": true },
+ "deadline_unix": { "type": "long", "index": true },
+ "comment_count": { "type": "long", "index": true }
+ }
+ }
+}
+`
+)
+
+// Index will save the index data
+func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) error {
+ if len(issues) == 0 {
+ return nil
+ } else if len(issues) == 1 {
+ issue := issues[0]
+ _, err := b.inner.Client.Index().
+ Index(b.inner.VersionedIndexName()).
+ Id(fmt.Sprintf("%d", issue.ID)).
+ BodyJson(issue).
+ Do(ctx)
+ return err
+ }
+
+ reqs := make([]elastic.BulkableRequest, 0)
+ for _, issue := range issues {
+ reqs = append(reqs,
+ elastic.NewBulkIndexRequest().
+ Index(b.inner.VersionedIndexName()).
+ Id(fmt.Sprintf("%d", issue.ID)).
+ Doc(issue),
+ )
+ }
+
+ _, err := b.inner.Client.Bulk().
+ Index(b.inner.VersionedIndexName()).
+ Add(reqs...).
+ Do(graceful.GetManager().HammerContext())
+ return err
+}
+
+// Delete deletes indexes by ids
+func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
+ if len(ids) == 0 {
+ return nil
+ } else if len(ids) == 1 {
+ _, err := b.inner.Client.Delete().
+ Index(b.inner.VersionedIndexName()).
+ Id(fmt.Sprintf("%d", ids[0])).
+ Do(ctx)
+ return err
+ }
+
+ reqs := make([]elastic.BulkableRequest, 0)
+ for _, id := range ids {
+ reqs = append(reqs,
+ elastic.NewBulkDeleteRequest().
+ Index(b.inner.VersionedIndexName()).
+ Id(fmt.Sprintf("%d", id)),
+ )
+ }
+
+ _, err := b.inner.Client.Bulk().
+ Index(b.inner.VersionedIndexName()).
+ Add(reqs...).
+ Do(graceful.GetManager().HammerContext())
+ return err
+}
+
+// Search searches for issues by given conditions.
+// Returns the matching issue IDs
+func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
+ query := elastic.NewBoolQuery()
+
+ if options.Keyword != "" {
+ searchType := esMultiMatchTypePhrasePrefix
+ if options.IsFuzzyKeyword {
+ searchType = esMultiMatchTypeBestFields
+ }
+
+ query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(searchType))
+ }
+
+ if len(options.RepoIDs) > 0 {
+ q := elastic.NewBoolQuery()
+ q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...))
+ if options.AllPublic {
+ q.Should(elastic.NewTermQuery("is_public", true))
+ }
+ query.Must(q)
+ }
+
+ if options.IsPull.Has() {
+ query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value()))
+ }
+ if options.IsClosed.Has() {
+ query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value()))
+ }
+
+ if options.NoLabelOnly {
+ query.Must(elastic.NewTermQuery("no_label", true))
+ } else {
+ if len(options.IncludedLabelIDs) > 0 {
+ q := elastic.NewBoolQuery()
+ for _, labelID := range options.IncludedLabelIDs {
+ q.Must(elastic.NewTermQuery("label_ids", labelID))
+ }
+ query.Must(q)
+ } else if len(options.IncludedAnyLabelIDs) > 0 {
+ query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...))
+ }
+ if len(options.ExcludedLabelIDs) > 0 {
+ q := elastic.NewBoolQuery()
+ for _, labelID := range options.ExcludedLabelIDs {
+ q.MustNot(elastic.NewTermQuery("label_ids", labelID))
+ }
+ query.Must(q)
+ }
+ }
+
+ if len(options.MilestoneIDs) > 0 {
+ query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
+ }
+
+ if options.ProjectID.Has() {
+ query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
+ }
+ if options.ProjectColumnID.Has() {
+ query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
+ }
+
+ if options.PosterID.Has() {
+ query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value()))
+ }
+
+ if options.AssigneeID.Has() {
+ query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value()))
+ }
+
+ if options.MentionID.Has() {
+ query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value()))
+ }
+
+ if options.ReviewedID.Has() {
+ query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value()))
+ }
+ if options.ReviewRequestedID.Has() {
+ query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
+ }
+
+ if options.SubscriberID.Has() {
+ query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value()))
+ }
+
+ if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
+ q := elastic.NewRangeQuery("updated_unix")
+ if options.UpdatedAfterUnix.Has() {
+ q.Gte(options.UpdatedAfterUnix.Value())
+ }
+ if options.UpdatedBeforeUnix.Has() {
+ q.Lte(options.UpdatedBeforeUnix.Value())
+ }
+ query.Must(q)
+ }
+
+ if options.SortBy == "" {
+ options.SortBy = internal.SortByCreatedAsc
+ }
+ sortBy := []elastic.Sorter{
+ parseSortBy(options.SortBy),
+ elastic.NewFieldSort("id").Desc(),
+ }
+
+ // See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900
+ // TODO: make it configurable since it's configurable in elasticsearch
+ const maxPageSize = 10000
+
+ skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize)
+ searchResult, err := b.inner.Client.Search().
+ Index(b.inner.VersionedIndexName()).
+ Query(query).
+ SortBy(sortBy...).
+ From(skip).Size(limit).
+ Do(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ hits := make([]internal.Match, 0, limit)
+ for _, hit := range searchResult.Hits.Hits {
+ id, _ := strconv.ParseInt(hit.Id, 10, 64)
+ hits = append(hits, internal.Match{
+ ID: id,
+ })
+ }
+
+ return &internal.SearchResult{
+ Total: searchResult.TotalHits(),
+ Hits: hits,
+ }, nil
+}
+
+func toAnySlice[T any](s []T) []any {
+ ret := make([]any, 0, len(s))
+ for _, item := range s {
+ ret = append(ret, item)
+ }
+ return ret
+}
+
+func parseSortBy(sortBy internal.SortBy) elastic.Sorter {
+ field := strings.TrimPrefix(string(sortBy), "-")
+ ret := elastic.NewFieldSort(field)
+ if strings.HasPrefix(string(sortBy), "-") {
+ ret.Desc()
+ }
+ return ret
+}
diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go
new file mode 100644
index 0000000..4ed0b84
--- /dev/null
+++ b/modules/indexer/issues/elasticsearch/elasticsearch_test.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package elasticsearch
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/indexer/issues/internal/tests"
+)
+
+func TestElasticsearchIndexer(t *testing.T) {
+ // The elasticsearch instance started by testing.yml > test-unit > services > elasticsearch
+ url := "http://elastic:changeme@elasticsearch:9200"
+
+ if os.Getenv("CI") == "" {
+ // Make it possible to run tests against a local elasticsearch instance
+ url = os.Getenv("TEST_ELASTICSEARCH_URL")
+ if url == "" {
+ t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
+ return
+ }
+ }
+
+ ok := false
+ for i := 0; i < 60; i++ {
+ resp, err := http.Get(url)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ ok = true
+ break
+ }
+ t.Logf("Waiting for elasticsearch to be up: %v", err)
+ time.Sleep(time.Second)
+ }
+ if !ok {
+ t.Fatalf("Failed to wait for elasticsearch to be up")
+ return
+ }
+
+ indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
+ defer indexer.Close()
+
+ tests.TestIndexer(t, indexer)
+}
diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go
new file mode 100644
index 0000000..d731052
--- /dev/null
+++ b/modules/indexer/issues/indexer.go
@@ -0,0 +1,315 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime/pprof"
+ "sync/atomic"
+ "time"
+
+ db_model "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/indexer/issues/bleve"
+ "code.gitea.io/gitea/modules/indexer/issues/db"
+ "code.gitea.io/gitea/modules/indexer/issues/elasticsearch"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/indexer/issues/meilisearch"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// IndexerMetadata is used to send data to the queue, so it contains only the ids.
+// It may look weird, because it has to be compatible with the old queue data format.
+// If the IsDelete flag is true, the IDs specify the issues to delete from the index without querying the database.
+// If the IsDelete flag is false, the ID specify the issue to index, so Indexer will query the database to get the issue data.
+// It should be noted that if the id is not existing in the database, it's index will be deleted too even if IsDelete is false.
+// Valid values:
+// - IsDelete = true, IDs = [1, 2, 3], and ID will be ignored
+// - IsDelete = false, ID = 1, and IDs will be ignored
+type IndexerMetadata struct {
+ ID int64 `json:"id"`
+
+ IsDelete bool `json:"is_delete"`
+ IDs []int64 `json:"ids"`
+}
+
+var (
+ // issueIndexerQueue queue of issue ids to be updated
+ issueIndexerQueue *queue.WorkerPoolQueue[*IndexerMetadata]
+ // globalIndexer is the global indexer, it cannot be nil.
+ // When the real indexer is not ready, it will be a dummy indexer which will return error to explain it's not ready.
+ // So it's always safe use it as *globalIndexer.Load() and call its methods.
+ globalIndexer atomic.Pointer[internal.Indexer]
+ dummyIndexer *internal.Indexer
+)
+
+func init() {
+ i := internal.NewDummyIndexer()
+ dummyIndexer = &i
+ globalIndexer.Store(dummyIndexer)
+}
+
+// InitIssueIndexer initialize issue indexer, syncReindex is true then reindex until
+// all issue index done.
+func InitIssueIndexer(syncReindex bool) {
+ ctx, _, finished := process.GetManager().AddTypedContext(context.Background(), "Service: IssueIndexer", process.SystemProcessType, false)
+
+ indexerInitWaitChannel := make(chan time.Duration, 1)
+
+ // Create the Queue
+ issueIndexerQueue = queue.CreateUniqueQueue(ctx, "issue_indexer", getIssueIndexerQueueHandler(ctx))
+
+ graceful.GetManager().RunAtTerminate(finished)
+
+ // Create the Indexer
+ go func() {
+ pprof.SetGoroutineLabels(ctx)
+ start := time.Now()
+ log.Info("PID %d: Initializing Issue Indexer: %s", os.Getpid(), setting.Indexer.IssueType)
+ var (
+ issueIndexer internal.Indexer
+ existed bool
+ err error
+ )
+ switch setting.Indexer.IssueType {
+ case "bleve":
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error("PANIC whilst initializing issue indexer: %v\nStacktrace: %s", err, log.Stack(2))
+ log.Error("The indexer files are likely corrupted and may need to be deleted")
+ log.Error("You can completely remove the %q directory to make Forgejo recreate the indexes", setting.Indexer.IssuePath)
+ globalIndexer.Store(dummyIndexer)
+ log.Fatal("PID: %d Unable to initialize the Bleve Issue Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.IssuePath, err)
+ }
+ }()
+ issueIndexer = bleve.NewIndexer(setting.Indexer.IssuePath)
+ existed, err = issueIndexer.Init(ctx)
+ if err != nil {
+ log.Fatal("Unable to initialize Bleve Issue Indexer at path: %s Error: %v", setting.Indexer.IssuePath, err)
+ }
+ case "elasticsearch":
+ issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName)
+ existed, err = issueIndexer.Init(ctx)
+ if err != nil {
+ log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
+ }
+ case "db":
+ issueIndexer = db.NewIndexer()
+ case "meilisearch":
+ issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
+ existed, err = issueIndexer.Init(ctx)
+ if err != nil {
+ log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
+ }
+ default:
+ log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType)
+ }
+ globalIndexer.Store(&issueIndexer)
+
+ graceful.GetManager().RunAtTerminate(func() {
+ log.Debug("Closing issue indexer")
+ (*globalIndexer.Load()).Close()
+ log.Info("PID: %d Issue Indexer closed", os.Getpid())
+ })
+
+ // Start processing the queue
+ go graceful.GetManager().RunWithCancel(issueIndexerQueue)
+
+ // Populate the index
+ if !existed {
+ if syncReindex {
+ graceful.GetManager().RunWithShutdownContext(populateIssueIndexer)
+ } else {
+ go graceful.GetManager().RunWithShutdownContext(populateIssueIndexer)
+ }
+ }
+
+ indexerInitWaitChannel <- time.Since(start)
+ close(indexerInitWaitChannel)
+ }()
+
+ if syncReindex {
+ select {
+ case <-indexerInitWaitChannel:
+ case <-graceful.GetManager().IsShutdown():
+ }
+ } else if setting.Indexer.StartupTimeout > 0 {
+ go func() {
+ pprof.SetGoroutineLabels(ctx)
+ timeout := setting.Indexer.StartupTimeout
+ if graceful.GetManager().IsChild() && setting.GracefulHammerTime > 0 {
+ timeout += setting.GracefulHammerTime
+ }
+ select {
+ case duration := <-indexerInitWaitChannel:
+ log.Info("Issue Indexer Initialization took %v", duration)
+ case <-graceful.GetManager().IsShutdown():
+ log.Warn("Shutdown occurred before issue index initialisation was complete")
+ case <-time.After(timeout):
+ issueIndexerQueue.ShutdownWait(5 * time.Second)
+ log.Fatal("Issue Indexer Initialization timed-out after: %v", timeout)
+ }
+ }()
+ }
+}
+
+func getIssueIndexerQueueHandler(ctx context.Context) func(items ...*IndexerMetadata) []*IndexerMetadata {
+ return func(items ...*IndexerMetadata) []*IndexerMetadata {
+ var unhandled []*IndexerMetadata
+
+ indexer := *globalIndexer.Load()
+ for _, item := range items {
+ log.Trace("IndexerMetadata Process: %d %v %t", item.ID, item.IDs, item.IsDelete)
+ if item.IsDelete {
+ if err := indexer.Delete(ctx, item.IDs...); err != nil {
+ log.Error("Issue indexer handler: failed to from index: %v Error: %v", item.IDs, err)
+ unhandled = append(unhandled, item)
+ }
+ continue
+ }
+ data, existed, err := getIssueIndexerData(ctx, item.ID)
+ if err != nil {
+ log.Error("Issue indexer handler: failed to get issue data of %d: %v", item.ID, err)
+ unhandled = append(unhandled, item)
+ continue
+ }
+ if !existed {
+ if err := indexer.Delete(ctx, item.ID); err != nil {
+ log.Error("Issue indexer handler: failed to delete issue %d from index: %v", item.ID, err)
+ unhandled = append(unhandled, item)
+ }
+ continue
+ }
+ if err := indexer.Index(ctx, data); err != nil {
+ log.Error("Issue indexer handler: failed to index issue %d: %v", item.ID, err)
+ unhandled = append(unhandled, item)
+ continue
+ }
+ }
+
+ return unhandled
+ }
+}
+
+// populateIssueIndexer populate the issue indexer with issue data
+func populateIssueIndexer(ctx context.Context) {
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: PopulateIssueIndexer", process.SystemProcessType, true)
+ defer finished()
+ ctx = contextWithKeepRetry(ctx) // keep retrying since it's a background task
+ if err := PopulateIssueIndexer(ctx); err != nil {
+ log.Error("Issue indexer population failed: %v", err)
+ }
+}
+
+func PopulateIssueIndexer(ctx context.Context) error {
+ for page := 1; ; page++ {
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("shutdown before completion: %w", ctx.Err())
+ default:
+ }
+ repos, _, err := repo_model.SearchRepositoryByName(ctx, &repo_model.SearchRepoOptions{
+ ListOptions: db_model.ListOptions{Page: page, PageSize: repo_model.RepositoryListDefaultPageSize},
+ OrderBy: db_model.SearchOrderByID,
+ Private: true,
+ Collaborate: optional.Some(false),
+ })
+ if err != nil {
+ log.Error("SearchRepositoryByName: %v", err)
+ continue
+ }
+ if len(repos) == 0 {
+ log.Debug("Issue Indexer population complete")
+ return nil
+ }
+
+ for _, repo := range repos {
+ if err := updateRepoIndexer(ctx, repo.ID); err != nil {
+ return fmt.Errorf("populate issue indexer for repo %d: %v", repo.ID, err)
+ }
+ }
+ }
+}
+
+// UpdateRepoIndexer add/update all issues of the repositories
+func UpdateRepoIndexer(ctx context.Context, repoID int64) {
+ if err := updateRepoIndexer(ctx, repoID); err != nil {
+ log.Error("Unable to push repo %d to issue indexer: %v", repoID, err)
+ }
+}
+
+// UpdateIssueIndexer add/update an issue to the issue indexer
+func UpdateIssueIndexer(ctx context.Context, issueID int64) {
+ if err := updateIssueIndexer(ctx, issueID); err != nil {
+ log.Error("Unable to push issue %d to issue indexer: %v", issueID, err)
+ }
+}
+
+// DeleteRepoIssueIndexer deletes repo's all issues indexes
+func DeleteRepoIssueIndexer(ctx context.Context, repoID int64) {
+ if err := deleteRepoIssueIndexer(ctx, repoID); err != nil {
+ log.Error("Unable to push deleted repo %d to issue indexer: %v", repoID, err)
+ }
+}
+
+// IsAvailable checks if issue indexer is available
+func IsAvailable(ctx context.Context) bool {
+ return (*globalIndexer.Load()).Ping(ctx) == nil
+}
+
+// SearchOptions indicates the options for searching issues
+type SearchOptions = internal.SearchOptions
+
+const (
+ SortByCreatedDesc = internal.SortByCreatedDesc
+ SortByUpdatedDesc = internal.SortByUpdatedDesc
+ SortByCommentsDesc = internal.SortByCommentsDesc
+ SortByDeadlineDesc = internal.SortByDeadlineDesc
+ SortByCreatedAsc = internal.SortByCreatedAsc
+ SortByUpdatedAsc = internal.SortByUpdatedAsc
+ SortByCommentsAsc = internal.SortByCommentsAsc
+ SortByDeadlineAsc = internal.SortByDeadlineAsc
+)
+
+// SearchIssues search issues by options.
+func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
+ indexer := *globalIndexer.Load()
+
+ if opts.Keyword == "" {
+ // This is a conservative shortcut.
+ // If the keyword is empty, db has better (at least not worse) performance to filter issues.
+ // When the keyword is empty, it tends to listing rather than searching issues.
+ // So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue.
+ // Even worse, the external indexer like elastic search may not be available for a while,
+ // and the user may not be able to list issues completely until it is available again.
+ indexer = db.NewIndexer()
+ }
+
+ result, err := indexer.Search(ctx, opts)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ ret := make([]int64, 0, len(result.Hits))
+ for _, hit := range result.Hits {
+ ret = append(ret, hit.ID)
+ }
+
+ return ret, result.Total, nil
+}
+
+// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
+func CountIssues(ctx context.Context, opts *SearchOptions) (int64, error) {
+ opts = opts.Copy(func(options *SearchOptions) { options.Paginator = &db_model.ListOptions{PageSize: 0} })
+
+ _, total, err := SearchIssues(ctx, opts)
+ return total, err
+}
diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go
new file mode 100644
index 0000000..a010218
--- /dev/null
+++ b/modules/indexer/issues/indexer_test.go
@@ -0,0 +1,410 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issues
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func TestDBSearchIssues(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ setting.Indexer.IssueType = "db"
+ InitIssueIndexer(true)
+
+ t.Run("search issues with keyword", searchIssueWithKeyword)
+ t.Run("search issues in repo", searchIssueInRepo)
+ t.Run("search issues by ID", searchIssueByID)
+ t.Run("search issues is pr", searchIssueIsPull)
+ t.Run("search issues is closed", searchIssueIsClosed)
+ t.Run("search issues by milestone", searchIssueByMilestoneID)
+ t.Run("search issues by label", searchIssueByLabelID)
+ t.Run("search issues by time", searchIssueByTime)
+ t.Run("search issues with order", searchIssueWithOrder)
+ t.Run("search issues in project", searchIssueInProject)
+ t.Run("search issues with paginator", searchIssueWithPaginator)
+}
+
+func searchIssueWithKeyword(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ Keyword: "issue2",
+ RepoIDs: []int64{1},
+ },
+ []int64{2},
+ },
+ {
+ SearchOptions{
+ Keyword: "first",
+ RepoIDs: []int64{1},
+ },
+ []int64{1},
+ },
+ {
+ SearchOptions{
+ Keyword: "for",
+ RepoIDs: []int64{1},
+ },
+ []int64{11, 5, 3, 2, 1},
+ },
+ {
+ SearchOptions{
+ Keyword: "good",
+ RepoIDs: []int64{1},
+ },
+ []int64{1},
+ },
+ }
+
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueInRepo(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ RepoIDs: []int64{1},
+ },
+ []int64{11, 5, 3, 2, 1},
+ },
+ {
+ SearchOptions{
+ RepoIDs: []int64{2},
+ },
+ []int64{7, 4},
+ },
+ {
+ SearchOptions{
+ RepoIDs: []int64{3},
+ },
+ []int64{12, 6},
+ },
+ {
+ SearchOptions{
+ RepoIDs: []int64{4},
+ },
+ []int64{},
+ },
+ {
+ SearchOptions{
+ RepoIDs: []int64{5},
+ },
+ []int64{15},
+ },
+ }
+
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueByID(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ opts: SearchOptions{
+ PosterID: optional.Some(int64(1)),
+ },
+ expectedIDs: []int64{11, 6, 3, 2, 1},
+ },
+ {
+ opts: SearchOptions{
+ AssigneeID: optional.Some(int64(1)),
+ },
+ expectedIDs: []int64{6, 1},
+ },
+ {
+ // NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
+ opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: -1}),
+ expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
+ },
+ {
+ opts: SearchOptions{
+ MentionID: optional.Some(int64(4)),
+ },
+ expectedIDs: []int64{1},
+ },
+ {
+ opts: SearchOptions{
+ ReviewedID: optional.Some(int64(1)),
+ },
+ expectedIDs: []int64{},
+ },
+ {
+ opts: SearchOptions{
+ ReviewRequestedID: optional.Some(int64(1)),
+ },
+ expectedIDs: []int64{12},
+ },
+ {
+ opts: SearchOptions{
+ SubscriberID: optional.Some(int64(1)),
+ },
+ expectedIDs: []int64{11, 6, 5, 3, 2, 1},
+ },
+ {
+ // issue 20 request user 15 and team 5 which user 15 belongs to
+ // the review request number of issue 20 should be 1
+ opts: SearchOptions{
+ ReviewRequestedID: optional.Some(int64(15)),
+ },
+ expectedIDs: []int64{12, 20},
+ },
+ {
+ // user 20 approved the issue 20, so return nothing
+ opts: SearchOptions{
+ ReviewRequestedID: optional.Some(int64(20)),
+ },
+ expectedIDs: []int64{},
+ },
+ }
+
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueIsPull(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ IsPull: optional.Some(false),
+ },
+ []int64{17, 16, 15, 14, 13, 6, 5, 18, 10, 7, 4, 1},
+ },
+ {
+ SearchOptions{
+ IsPull: optional.Some(true),
+ },
+ []int64{22, 21, 12, 11, 20, 19, 9, 8, 3, 2},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueIsClosed(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ IsClosed: optional.Some(false),
+ },
+ []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 19, 18, 10, 7, 9, 8, 3, 2, 1},
+ },
+ {
+ SearchOptions{
+ IsClosed: optional.Some(true),
+ },
+ []int64{5, 4},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueByMilestoneID(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ MilestoneIDs: []int64{1},
+ },
+ []int64{2},
+ },
+ {
+ SearchOptions{
+ MilestoneIDs: []int64{3},
+ },
+ []int64{3},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueByLabelID(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ IncludedLabelIDs: []int64{1},
+ },
+ []int64{2, 1},
+ },
+ {
+ SearchOptions{
+ IncludedLabelIDs: []int64{4},
+ },
+ []int64{2},
+ },
+ {
+ SearchOptions{
+ ExcludedLabelIDs: []int64{1},
+ },
+ []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueByTime(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ UpdatedAfterUnix: optional.Some(int64(0)),
+ },
+ []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 1},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueWithOrder(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ SortBy: internal.SortByCreatedAsc,
+ },
+ []int64{1, 2, 3, 8, 9, 4, 7, 10, 18, 19, 5, 6, 20, 11, 12, 13, 14, 15, 16, 17, 21, 22},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueInProject(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ }{
+ {
+ SearchOptions{
+ ProjectID: optional.Some(int64(1)),
+ },
+ []int64{5, 3, 2, 1},
+ },
+ {
+ SearchOptions{
+ ProjectColumnID: optional.Some(int64(1)),
+ },
+ []int64{1},
+ },
+ {
+ SearchOptions{
+ ProjectColumnID: optional.Some(int64(0)), // issue with in default column
+ },
+ []int64{2},
+ },
+ }
+ for _, test := range tests {
+ issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ }
+}
+
+func searchIssueWithPaginator(t *testing.T) {
+ tests := []struct {
+ opts SearchOptions
+ expectedIDs []int64
+ expectedTotal int64
+ }{
+ {
+ SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ },
+ []int64{22, 21, 17, 16, 15},
+ 22,
+ },
+ }
+ for _, test := range tests {
+ issueIDs, total, err := SearchIssues(context.TODO(), &test.opts)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expectedIDs, issueIDs)
+ assert.Equal(t, test.expectedTotal, total)
+ }
+}
diff --git a/modules/indexer/issues/internal/indexer.go b/modules/indexer/issues/internal/indexer.go
new file mode 100644
index 0000000..95740bc
--- /dev/null
+++ b/modules/indexer/issues/internal/indexer.go
@@ -0,0 +1,42 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/indexer/internal"
+)
+
+// Indexer defines an interface to indexer issues contents
+type Indexer interface {
+ internal.Indexer
+ Index(ctx context.Context, issue ...*IndexerData) error
+ Delete(ctx context.Context, ids ...int64) error
+ Search(ctx context.Context, options *SearchOptions) (*SearchResult, error)
+}
+
+// NewDummyIndexer returns a dummy indexer
+func NewDummyIndexer() Indexer {
+ return &dummyIndexer{
+ Indexer: internal.NewDummyIndexer(),
+ }
+}
+
+type dummyIndexer struct {
+ internal.Indexer
+}
+
+func (d *dummyIndexer) Index(_ context.Context, _ ...*IndexerData) error {
+ return fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Delete(_ context.Context, _ ...int64) error {
+ return fmt.Errorf("indexer is not ready")
+}
+
+func (d *dummyIndexer) Search(_ context.Context, _ *SearchOptions) (*SearchResult, error) {
+ return nil, fmt.Errorf("indexer is not ready")
+}
diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go
new file mode 100644
index 0000000..2dfee8b
--- /dev/null
+++ b/modules/indexer/issues/internal/model.go
@@ -0,0 +1,150 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+import (
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// IndexerData data stored in the issue indexer
+type IndexerData struct {
+ ID int64 `json:"id"`
+ RepoID int64 `json:"repo_id"`
+ IsPublic bool `json:"is_public"` // If the repo is public
+
+ // Fields used for keyword searching
+ Title string `json:"title"`
+ Content string `json:"content"`
+ Comments []string `json:"comments"`
+
+ // Fields used for filtering
+ IsPull bool `json:"is_pull"`
+ IsClosed bool `json:"is_closed"`
+ LabelIDs []int64 `json:"label_ids"`
+ NoLabel bool `json:"no_label"` // True if LabelIDs is empty
+ MilestoneID int64 `json:"milestone_id"`
+ ProjectID int64 `json:"project_id"`
+ ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
+ PosterID int64 `json:"poster_id"`
+ AssigneeID int64 `json:"assignee_id"`
+ MentionIDs []int64 `json:"mention_ids"`
+ ReviewedIDs []int64 `json:"reviewed_ids"`
+ ReviewRequestedIDs []int64 `json:"review_requested_ids"`
+ SubscriberIDs []int64 `json:"subscriber_ids"`
+ UpdatedUnix timeutil.TimeStamp `json:"updated_unix"`
+
+ // Fields used for sorting
+ // UpdatedUnix is both used for filtering and sorting.
+ // ID is used for sorting too, to make the sorting stable.
+ CreatedUnix timeutil.TimeStamp `json:"created_unix"`
+ DeadlineUnix timeutil.TimeStamp `json:"deadline_unix"`
+ CommentCount int64 `json:"comment_count"`
+}
+
+// Match represents on search result
+type Match struct {
+ ID int64 `json:"id"`
+ Score float64 `json:"score"`
+}
+
+// SearchResult represents search results
+type SearchResult struct {
+ Total int64
+ Hits []Match
+}
+
+// SearchOptions represents search options.
+//
+// It has a slightly different design from database query options.
+// In database query options, a field is never a pointer, so it could be confusing when it's zero value:
+// Do you want to find data with a field value of 0, or do you not specify the field in the options?
+// To avoid this confusion, db introduced db.NoConditionID(-1).
+// So zero value means the field is not specified in the search options, and db.NoConditionID means "== 0" or "id NOT IN (SELECT id FROM ...)"
+// It's still not ideal, it trapped developers many times.
+// And sometimes -1 could be a valid value, like issue ID, negative numbers indicate exclusion.
+// Since db.NoConditionID is for "db" (the package name is db), it makes sense not to use it in the indexer:
+// Why do bleve/elasticsearch/meilisearch indexers need to know about db.NoConditionID?
+// So in SearchOptions, we use pointer for fields which could be not specified,
+// and always use the value to filter if it's not nil, even if it's zero or negative.
+// It can handle almost all cases, if there is an exception, we can add a new field, like NoLabelOnly.
+// Unfortunately, we still use db for the indexer and have to convert between db.NoConditionID and nil for legacy reasons.
+type SearchOptions struct {
+ Keyword string // keyword to search
+
+ IsFuzzyKeyword bool // if false the levenshtein distance is 0
+
+ RepoIDs []int64 // repository IDs which the issues belong to
+ AllPublic bool // if include all public repositories
+
+ IsPull optional.Option[bool] // if the issues is a pull request
+ IsClosed optional.Option[bool] // if the issues is closed
+
+ IncludedLabelIDs []int64 // labels the issues have
+ ExcludedLabelIDs []int64 // labels the issues don't have
+ IncludedAnyLabelIDs []int64 // labels the issues have at least one. It will be ignored if IncludedLabelIDs is not empty. It's an uncommon filter, but it has been supported accidentally by issues.IssuesOptions.IncludedLabelNames.
+ NoLabelOnly bool // if the issues have no label, if true, IncludedLabelIDs and ExcludedLabelIDs, IncludedAnyLabelIDs will be ignored
+
+ MilestoneIDs []int64 // milestones the issues have
+
+ ProjectID optional.Option[int64] // project the issues belong to
+ ProjectColumnID optional.Option[int64] // project column the issues belong to
+
+ PosterID optional.Option[int64] // poster of the issues
+
+ AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee
+
+ MentionID optional.Option[int64] // mentioned user of the issues
+
+ ReviewedID optional.Option[int64] // reviewer of the issues
+ ReviewRequestedID optional.Option[int64] // requested reviewer of the issues
+
+ SubscriberID optional.Option[int64] // subscriber of the issues
+
+ UpdatedAfterUnix optional.Option[int64]
+ UpdatedBeforeUnix optional.Option[int64]
+
+ Paginator *db.ListOptions
+
+ SortBy SortBy // sort by field
+}
+
+// Copy returns a copy of the options.
+// Be careful, it's not a deep copy, so `SearchOptions.RepoIDs = {...}` is OK while `SearchOptions.RepoIDs[0] = ...` is not.
+func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOptions {
+ if o == nil {
+ return nil
+ }
+ v := *o
+ for _, e := range edit {
+ e(&v)
+ }
+ return &v
+}
+
+type SortBy string
+
+const (
+ SortByCreatedDesc SortBy = "-created_unix"
+ SortByUpdatedDesc SortBy = "-updated_unix"
+ SortByCommentsDesc SortBy = "-comment_count"
+ SortByDeadlineDesc SortBy = "-deadline_unix"
+ SortByCreatedAsc SortBy = "created_unix"
+ SortByUpdatedAsc SortBy = "updated_unix"
+ SortByCommentsAsc SortBy = "comment_count"
+ SortByDeadlineAsc SortBy = "deadline_unix"
+ // Unsupported sort types which are supported by issues.IssuesOptions.SortType:
+ //
+ // - "priorityrepo":
+ // It's impossible to support it in the indexer.
+ // It is based on the specified repository in the request, so we cannot add static field to the indexer.
+ // If we do something like that query the issues in the specified repository first then append other issues,
+ // it will break the pagination.
+ //
+ // - "project-column-sorting":
+ // Although it's possible to support it by adding project.ProjectIssue.Sorting to the indexer,
+ // but what if the issue belongs to multiple projects?
+ // Since it's unsupported to search issues with keyword in project page, we don't need to support it.
+)
diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go
new file mode 100644
index 0000000..a93b291
--- /dev/null
+++ b/modules/indexer/issues/internal/tests/tests.go
@@ -0,0 +1,771 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This package contains tests for issues indexer modules.
+// All the code in this package is only used for testing.
+// Do not put any production code in this package to avoid it being included in the final binary.
+
+package tests
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIndexer(t *testing.T, indexer internal.Indexer) {
+ _, err := indexer.Init(context.Background())
+ require.NoError(t, err)
+
+ require.NoError(t, indexer.Ping(context.Background()))
+
+ var (
+ ids []int64
+ data = map[int64]*internal.IndexerData{}
+ )
+ {
+ d := generateDefaultIndexerData()
+ for _, v := range d {
+ ids = append(ids, v.ID)
+ data[v.ID] = v
+ }
+ require.NoError(t, indexer.Index(context.Background(), d...))
+ require.NoError(t, waitData(indexer, int64(len(data))))
+ }
+
+ defer func() {
+ require.NoError(t, indexer.Delete(context.Background(), ids...))
+ }()
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ if len(c.ExtraData) > 0 {
+ require.NoError(t, indexer.Index(context.Background(), c.ExtraData...))
+ for _, v := range c.ExtraData {
+ data[v.ID] = v
+ }
+ require.NoError(t, waitData(indexer, int64(len(data))))
+ defer func() {
+ for _, v := range c.ExtraData {
+ require.NoError(t, indexer.Delete(context.Background(), v.ID))
+ delete(data, v.ID)
+ }
+ require.NoError(t, waitData(indexer, int64(len(data))))
+ }()
+ }
+
+ result, err := indexer.Search(context.Background(), c.SearchOptions)
+ require.NoError(t, err)
+
+ if c.Expected != nil {
+ c.Expected(t, data, result)
+ } else {
+ ids := make([]int64, 0, len(result.Hits))
+ for _, hit := range result.Hits {
+ ids = append(ids, hit.ID)
+ }
+ assert.Equal(t, c.ExpectedIDs, ids)
+ assert.Equal(t, c.ExpectedTotal, result.Total)
+ }
+
+ // test counting
+ c.SearchOptions.Paginator = &db.ListOptions{PageSize: 0}
+ countResult, err := indexer.Search(context.Background(), c.SearchOptions)
+ require.NoError(t, err)
+ assert.Empty(t, countResult.Hits)
+ assert.Equal(t, result.Total, countResult.Total)
+ })
+ }
+}
+
+var cases = []*testIndexerCase{
+ {
+ Name: "default",
+ SearchOptions: &internal.SearchOptions{},
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ },
+ },
+ {
+ Name: "empty",
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
+ },
+ ExpectedIDs: []int64{},
+ ExpectedTotal: 0,
+ },
+ {
+ Name: "with limit",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ assert.Equal(t, len(data), int(result.Total))
+ },
+ },
+ {
+ Name: "Keyword",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1000, Title: "hi hello world"},
+ {ID: 1001, Content: "hi hello world"},
+ {ID: 1002, Comments: []string{"hi", "hello world"}},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello",
+ },
+ ExpectedIDs: []int64{1002, 1001, 1000},
+ ExpectedTotal: 3,
+ },
+ {
+ Name: "Keyword Fuzzy",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1000, Title: "hi hello world"},
+ {ID: 1001, Content: "hi hello world"},
+ {ID: 1002, Comments: []string{"hi", "hello world"}},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello world",
+ IsFuzzyKeyword: true,
+ },
+ ExpectedIDs: []int64{1002, 1001, 1000},
+ ExpectedTotal: 3,
+ },
+ {
+ Name: "RepoIDs",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false},
+ {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false},
+ {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true},
+ {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true},
+ {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true},
+ {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
+ {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello",
+ RepoIDs: []int64{1, 4},
+ },
+ ExpectedIDs: []int64{1006, 1002, 1001},
+ ExpectedTotal: 3,
+ },
+ {
+ Name: "RepoIDs and AllPublic",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false},
+ {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false},
+ {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true},
+ {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true},
+ {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true},
+ {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
+ {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello",
+ RepoIDs: []int64{1, 4},
+ AllPublic: true,
+ },
+ ExpectedIDs: []int64{1006, 1005, 1004, 1003, 1002, 1001},
+ ExpectedTotal: 6,
+ },
+ {
+ Name: "issue only",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ IsPull: optional.Some(false),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.False(t, data[v.ID].IsPull)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsPull }), result.Total)
+ },
+ },
+ {
+ Name: "pull only",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ IsPull: optional.Some(true),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.True(t, data[v.ID].IsPull)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsPull }), result.Total)
+ },
+ },
+ {
+ Name: "opened only",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ IsClosed: optional.Some(false),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.False(t, data[v.ID].IsClosed)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsClosed }), result.Total)
+ },
+ },
+ {
+ Name: "closed only",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ IsClosed: optional.Some(true),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.True(t, data[v.ID].IsClosed)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsClosed }), result.Total)
+ },
+ },
+ {
+ Name: "labels",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}},
+ {ID: 1001, Title: "hello b", LabelIDs: []int64{2000, 2001}},
+ {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}},
+ {ID: 1003, Title: "hello d", LabelIDs: []int64{2000}},
+ {ID: 1004, Title: "hello e", LabelIDs: []int64{}},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello",
+ IncludedLabelIDs: []int64{2000, 2001},
+ ExcludedLabelIDs: []int64{2003},
+ },
+ ExpectedIDs: []int64{1001, 1000},
+ ExpectedTotal: 2,
+ },
+ {
+ Name: "include any labels",
+ ExtraData: []*internal.IndexerData{
+ {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}},
+ {ID: 1001, Title: "hello b", LabelIDs: []int64{2001}},
+ {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}},
+ {ID: 1003, Title: "hello d", LabelIDs: []int64{2002}},
+ {ID: 1004, Title: "hello e", LabelIDs: []int64{}},
+ },
+ SearchOptions: &internal.SearchOptions{
+ Keyword: "hello",
+ IncludedAnyLabelIDs: []int64{2001, 2002},
+ ExcludedLabelIDs: []int64{2003},
+ },
+ ExpectedIDs: []int64{1003, 1001, 1000},
+ ExpectedTotal: 3,
+ },
+ {
+ Name: "MilestoneIDs",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ MilestoneIDs: []int64{1, 2, 6},
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Contains(t, []int64{1, 2, 6}, data[v.ID].MilestoneID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.MilestoneID == 1 || v.MilestoneID == 2 || v.MilestoneID == 6
+ }), result.Total)
+ },
+ },
+ {
+ Name: "no MilestoneIDs",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ MilestoneIDs: []int64{0},
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(0), data[v.ID].MilestoneID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.MilestoneID == 0
+ }), result.Total)
+ },
+ },
+ {
+ Name: "ProjectID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ProjectID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(1), data[v.ID].ProjectID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.ProjectID == 1
+ }), result.Total)
+ },
+ },
+ {
+ Name: "no ProjectID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ProjectID: optional.Some(int64(0)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(0), data[v.ID].ProjectID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.ProjectID == 0
+ }), result.Total)
+ },
+ },
+ {
+ Name: "ProjectColumnID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ProjectColumnID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(1), data[v.ID].ProjectColumnID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.ProjectColumnID == 1
+ }), result.Total)
+ },
+ },
+ {
+ Name: "no ProjectColumnID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ProjectColumnID: optional.Some(int64(0)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(0), data[v.ID].ProjectColumnID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.ProjectColumnID == 0
+ }), result.Total)
+ },
+ },
+ {
+ Name: "PosterID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ PosterID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(1), data[v.ID].PosterID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.PosterID == 1
+ }), result.Total)
+ },
+ },
+ {
+ Name: "AssigneeID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ AssigneeID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(1), data[v.ID].AssigneeID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.AssigneeID == 1
+ }), result.Total)
+ },
+ },
+ {
+ Name: "no AssigneeID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ AssigneeID: optional.Some(int64(0)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Equal(t, int64(0), data[v.ID].AssigneeID)
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return v.AssigneeID == 0
+ }), result.Total)
+ },
+ },
+ {
+ Name: "MentionID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ MentionID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Contains(t, data[v.ID].MentionIDs, int64(1))
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return slices.Contains(v.MentionIDs, 1)
+ }), result.Total)
+ },
+ },
+ {
+ Name: "ReviewedID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ReviewedID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Contains(t, data[v.ID].ReviewedIDs, int64(1))
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return slices.Contains(v.ReviewedIDs, 1)
+ }), result.Total)
+ },
+ },
+ {
+ Name: "ReviewRequestedID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ ReviewRequestedID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Contains(t, data[v.ID].ReviewRequestedIDs, int64(1))
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return slices.Contains(v.ReviewRequestedIDs, 1)
+ }), result.Total)
+ },
+ },
+ {
+ Name: "SubscriberID",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ SubscriberID: optional.Some(int64(1)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.Contains(t, data[v.ID].SubscriberIDs, int64(1))
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return slices.Contains(v.SubscriberIDs, 1)
+ }), result.Total)
+ },
+ },
+ {
+ Name: "updated",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 5,
+ },
+ UpdatedAfterUnix: optional.Some(int64(20)),
+ UpdatedBeforeUnix: optional.Some(int64(30)),
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Len(t, result.Hits, 5)
+ for _, v := range result.Hits {
+ assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, int64(20))
+ assert.LessOrEqual(t, data[v.ID].UpdatedUnix, int64(30))
+ }
+ assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
+ return data[v.ID].UpdatedUnix >= 20 && data[v.ID].UpdatedUnix <= 30
+ }), result.Total)
+ },
+ },
+ {
+ Name: "SortByCreatedDesc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByCreatedDesc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.GreaterOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByUpdatedDesc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByUpdatedDesc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByCommentsDesc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByCommentsDesc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.GreaterOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByDeadlineDesc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByDeadlineDesc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.GreaterOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByCreatedAsc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByCreatedAsc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.LessOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByUpdatedAsc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByUpdatedAsc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.LessOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByCommentsAsc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByCommentsAsc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.LessOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount)
+ }
+ }
+ },
+ },
+ {
+ Name: "SortByDeadlineAsc",
+ SearchOptions: &internal.SearchOptions{
+ Paginator: &db.ListOptionsAll,
+ SortBy: internal.SortByDeadlineAsc,
+ },
+ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
+ assert.Equal(t, len(data), len(result.Hits))
+ assert.Equal(t, len(data), int(result.Total))
+ for i, v := range result.Hits {
+ if i < len(result.Hits)-1 {
+ assert.LessOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix)
+ }
+ }
+ },
+ },
+}
+
+type testIndexerCase struct {
+ Name string
+ ExtraData []*internal.IndexerData
+
+ SearchOptions *internal.SearchOptions
+
+ Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal
+ ExpectedIDs []int64
+ ExpectedTotal int64
+}
+
+func generateDefaultIndexerData() []*internal.IndexerData {
+ var id int64
+ var data []*internal.IndexerData
+ for repoID := int64(1); repoID <= 10; repoID++ {
+ for issueIndex := int64(1); issueIndex <= 20; issueIndex++ {
+ id++
+
+ comments := make([]string, id%4)
+ for i := range comments {
+ comments[i] = fmt.Sprintf("comment%d", i)
+ }
+
+ labelIDs := make([]int64, id%5)
+ for i := range labelIDs {
+ labelIDs[i] = int64(i) + 1 // LabelID should not be 0
+ }
+ mentionIDs := make([]int64, id%6)
+ for i := range mentionIDs {
+ mentionIDs[i] = int64(i) + 1 // MentionID should not be 0
+ }
+ reviewedIDs := make([]int64, id%7)
+ for i := range reviewedIDs {
+ reviewedIDs[i] = int64(i) + 1 // ReviewID should not be 0
+ }
+ reviewRequestedIDs := make([]int64, id%8)
+ for i := range reviewRequestedIDs {
+ reviewRequestedIDs[i] = int64(i) + 1 // ReviewRequestedID should not be 0
+ }
+ subscriberIDs := make([]int64, id%9)
+ for i := range subscriberIDs {
+ subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
+ }
+
+ data = append(data, &internal.IndexerData{
+ ID: id,
+ RepoID: repoID,
+ IsPublic: repoID%2 == 0,
+ Title: fmt.Sprintf("issue%d of repo%d", issueIndex, repoID),
+ Content: fmt.Sprintf("content%d", issueIndex),
+ Comments: comments,
+ IsPull: issueIndex%2 == 0,
+ IsClosed: issueIndex%3 == 0,
+ LabelIDs: labelIDs,
+ NoLabel: len(labelIDs) == 0,
+ MilestoneID: issueIndex % 4,
+ ProjectID: issueIndex % 5,
+ ProjectColumnID: issueIndex % 6,
+ PosterID: id%10 + 1, // PosterID should not be 0
+ AssigneeID: issueIndex % 10,
+ MentionIDs: mentionIDs,
+ ReviewedIDs: reviewedIDs,
+ ReviewRequestedIDs: reviewRequestedIDs,
+ SubscriberIDs: subscriberIDs,
+ UpdatedUnix: timeutil.TimeStamp(id + issueIndex),
+ CreatedUnix: timeutil.TimeStamp(id),
+ DeadlineUnix: timeutil.TimeStamp(id + issueIndex + repoID),
+ CommentCount: int64(len(comments)),
+ })
+ }
+ }
+
+ return data
+}
+
+func countIndexerData(data map[int64]*internal.IndexerData, f func(v *internal.IndexerData) bool) int64 {
+ var count int64
+ for _, v := range data {
+ if f(v) {
+ count++
+ }
+ }
+ return count
+}
+
+// waitData waits for the indexer to index all data.
+// Some engines like Elasticsearch index data asynchronously, so we need to wait for a while.
+func waitData(indexer internal.Indexer, total int64) error {
+ var actual int64
+ for i := 0; i < 100; i++ {
+ result, err := indexer.Search(context.Background(), &internal.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: 0,
+ },
+ })
+ if err != nil {
+ return err
+ }
+ actual = result.Total
+ if actual == total {
+ return nil
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ return fmt.Errorf("waitData: expected %d, actual %d", total, actual)
+}
diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go
new file mode 100644
index 0000000..7d18444
--- /dev/null
+++ b/modules/indexer/issues/meilisearch/meilisearch.go
@@ -0,0 +1,301 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package meilisearch
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
+ inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+
+ "github.com/meilisearch/meilisearch-go"
+)
+
+const (
+ issueIndexerLatestVersion = 3
+
+ // TODO: make this configurable if necessary
+ maxTotalHits = 10000
+)
+
+// ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types.
+var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content")
+
+var _ internal.Indexer = &Indexer{}
+
+// Indexer implements Indexer interface
+type Indexer struct {
+ inner *inner_meilisearch.Indexer
+ indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much
+}
+
+// NewIndexer creates a new meilisearch indexer
+func NewIndexer(url, apiKey, indexerName string) *Indexer {
+ settings := &meilisearch.Settings{
+ // The default ranking rules of meilisearch are: ["words", "typo", "proximity", "attribute", "sort", "exactness"]
+ // So even if we specify the sort order, it could not be respected because the priority of "sort" is so low.
+ // So we need to specify the ranking rules to make sure the sort order is respected.
+ // See https://www.meilisearch.com/docs/learn/core_concepts/relevancy
+ RankingRules: []string{"sort", // make sure "sort" has the highest priority
+ "words", "typo", "proximity", "attribute", "exactness"},
+
+ SearchableAttributes: []string{
+ "title",
+ "content",
+ "comments",
+ },
+ DisplayedAttributes: []string{
+ "id",
+ "title",
+ "content",
+ "comments",
+ },
+ FilterableAttributes: []string{
+ "repo_id",
+ "is_public",
+ "is_pull",
+ "is_closed",
+ "label_ids",
+ "no_label",
+ "milestone_id",
+ "project_id",
+ "project_board_id",
+ "poster_id",
+ "assignee_id",
+ "mention_ids",
+ "reviewed_ids",
+ "review_requested_ids",
+ "subscriber_ids",
+ "updated_unix",
+ },
+ SortableAttributes: []string{
+ "updated_unix",
+ "created_unix",
+ "deadline_unix",
+ "comment_count",
+ "id",
+ },
+ Pagination: &meilisearch.Pagination{
+ MaxTotalHits: maxTotalHits,
+ },
+ }
+
+ inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, issueIndexerLatestVersion, settings)
+ indexer := &Indexer{
+ inner: inner,
+ Indexer: inner,
+ }
+ return indexer
+}
+
+// Index will save the index data
+func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error {
+ if len(issues) == 0 {
+ return nil
+ }
+ for _, issue := range issues {
+ _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).AddDocuments(issue)
+ if err != nil {
+ return err
+ }
+ }
+ // TODO: bulk send index data
+ return nil
+}
+
+// Delete deletes indexes by ids
+func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
+ if len(ids) == 0 {
+ return nil
+ }
+
+ for _, id := range ids {
+ _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10))
+ if err != nil {
+ return err
+ }
+ }
+ // TODO: bulk send deletes
+ return nil
+}
+
+// Search searches for issues by given conditions.
+// Returns the matching issue IDs
+func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
+ query := inner_meilisearch.FilterAnd{}
+
+ if len(options.RepoIDs) > 0 {
+ q := &inner_meilisearch.FilterOr{}
+ q.Or(inner_meilisearch.NewFilterIn("repo_id", options.RepoIDs...))
+ if options.AllPublic {
+ q.Or(inner_meilisearch.NewFilterEq("is_public", true))
+ }
+ query.And(q)
+ }
+
+ if options.IsPull.Has() {
+ query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.Value()))
+ }
+ if options.IsClosed.Has() {
+ query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.Value()))
+ }
+
+ if options.NoLabelOnly {
+ query.And(inner_meilisearch.NewFilterEq("no_label", true))
+ } else {
+ if len(options.IncludedLabelIDs) > 0 {
+ q := &inner_meilisearch.FilterAnd{}
+ for _, labelID := range options.IncludedLabelIDs {
+ q.And(inner_meilisearch.NewFilterEq("label_ids", labelID))
+ }
+ query.And(q)
+ } else if len(options.IncludedAnyLabelIDs) > 0 {
+ query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...))
+ }
+ if len(options.ExcludedLabelIDs) > 0 {
+ q := &inner_meilisearch.FilterAnd{}
+ for _, labelID := range options.ExcludedLabelIDs {
+ q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID)))
+ }
+ query.And(q)
+ }
+ }
+
+ if len(options.MilestoneIDs) > 0 {
+ query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
+ }
+
+ if options.ProjectID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
+ }
+ if options.ProjectColumnID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
+ }
+
+ if options.PosterID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value()))
+ }
+
+ if options.AssigneeID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value()))
+ }
+
+ if options.MentionID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("mention_ids", options.MentionID.Value()))
+ }
+
+ if options.ReviewedID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("reviewed_ids", options.ReviewedID.Value()))
+ }
+ if options.ReviewRequestedID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("review_requested_ids", options.ReviewRequestedID.Value()))
+ }
+
+ if options.SubscriberID.Has() {
+ query.And(inner_meilisearch.NewFilterEq("subscriber_ids", options.SubscriberID.Value()))
+ }
+
+ if options.UpdatedAfterUnix.Has() {
+ query.And(inner_meilisearch.NewFilterGte("updated_unix", options.UpdatedAfterUnix.Value()))
+ }
+ if options.UpdatedBeforeUnix.Has() {
+ query.And(inner_meilisearch.NewFilterLte("updated_unix", options.UpdatedBeforeUnix.Value()))
+ }
+
+ if options.SortBy == "" {
+ options.SortBy = internal.SortByCreatedAsc
+ }
+ sortBy := []string{
+ parseSortBy(options.SortBy),
+ "id:desc",
+ }
+
+ skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxTotalHits)
+
+ counting := limit == 0
+ if counting {
+ // If set limit to 0, it will be 20 by default, and -1 is not allowed.
+ // See https://www.meilisearch.com/docs/reference/api/search#limit
+ // So set limit to 1 to make the cost as low as possible, then clear the result before returning.
+ limit = 1
+ }
+
+ keyword := options.Keyword
+ if !options.IsFuzzyKeyword {
+ // to make it non fuzzy ("typo tolerance" in meilisearch terms), we have to quote the keyword(s)
+ // https://www.meilisearch.com/docs/reference/api/search#phrase-search
+ keyword = doubleQuoteKeyword(keyword)
+ }
+
+ searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(keyword, &meilisearch.SearchRequest{
+ Filter: query.Statement(),
+ Limit: int64(limit),
+ Offset: int64(skip),
+ Sort: sortBy,
+ MatchingStrategy: meilisearch.All,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if counting {
+ searchRes.Hits = nil
+ }
+
+ hits, err := convertHits(searchRes)
+ if err != nil {
+ return nil, err
+ }
+
+ return &internal.SearchResult{
+ Total: searchRes.EstimatedTotalHits,
+ Hits: hits,
+ }, nil
+}
+
+func parseSortBy(sortBy internal.SortBy) string {
+ field := strings.TrimPrefix(string(sortBy), "-")
+ if strings.HasPrefix(string(sortBy), "-") {
+ return field + ":desc"
+ }
+ return field + ":asc"
+}
+
+func doubleQuoteKeyword(k string) string {
+ kp := strings.Split(k, " ")
+ parts := 0
+ for i := range kp {
+ part := strings.Trim(kp[i], "\"")
+ if part != "" {
+ kp[parts] = fmt.Sprintf(`"%s"`, part)
+ parts++
+ }
+ }
+ return strings.Join(kp[:parts], " ")
+}
+
+func convertHits(searchRes *meilisearch.SearchResponse) ([]internal.Match, error) {
+ hits := make([]internal.Match, 0, len(searchRes.Hits))
+ for _, hit := range searchRes.Hits {
+ hit, ok := hit.(map[string]any)
+ if !ok {
+ return nil, ErrMalformedResponse
+ }
+
+ issueID, ok := hit["id"].(float64)
+ if !ok {
+ return nil, ErrMalformedResponse
+ }
+
+ hits = append(hits, internal.Match{
+ ID: int64(issueID),
+ })
+ }
+ return hits, nil
+}
diff --git a/modules/indexer/issues/meilisearch/meilisearch_test.go b/modules/indexer/issues/meilisearch/meilisearch_test.go
new file mode 100644
index 0000000..349102b
--- /dev/null
+++ b/modules/indexer/issues/meilisearch/meilisearch_test.go
@@ -0,0 +1,97 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package meilisearch
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/indexer/issues/internal/tests"
+
+ "github.com/meilisearch/meilisearch-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMeilisearchIndexer(t *testing.T) {
+ t.Skip("meilisearch not found in Forgejo test yet")
+ // The meilisearch instance started by pull-db-tests.yml > test-unit > services > meilisearch
+ url := "http://meilisearch:7700"
+ key := "" // auth has been disabled in test environment
+
+ if os.Getenv("CI") == "" {
+ // Make it possible to run tests against a local meilisearch instance
+ url = os.Getenv("TEST_MEILISEARCH_URL")
+ if url == "" {
+ t.Skip("TEST_MEILISEARCH_URL not set and not running in CI")
+ return
+ }
+ key = os.Getenv("TEST_MEILISEARCH_KEY")
+ }
+
+ ok := false
+ for i := 0; i < 60; i++ {
+ resp, err := http.Get(url)
+ if err == nil && resp.StatusCode == http.StatusOK {
+ ok = true
+ break
+ }
+ t.Logf("Waiting for meilisearch to be up: %v", err)
+ time.Sleep(time.Second)
+ }
+ if !ok {
+ t.Fatalf("Failed to wait for meilisearch to be up")
+ return
+ }
+
+ indexer := NewIndexer(url, key, fmt.Sprintf("test_meilisearch_indexer_%d", time.Now().Unix()))
+ defer indexer.Close()
+
+ tests.TestIndexer(t, indexer)
+}
+
+func TestConvertHits(t *testing.T) {
+ _, err := convertHits(&meilisearch.SearchResponse{
+ Hits: []any{"aa", "bb", "cc", "dd"},
+ })
+ require.ErrorIs(t, err, ErrMalformedResponse)
+
+ validResponse := &meilisearch.SearchResponse{
+ Hits: []any{
+ map[string]any{
+ "id": float64(11),
+ "title": "a title",
+ "content": "issue body with no match",
+ "comments": []any{"hey what's up?", "I'm currently bowling", "nice"},
+ },
+ map[string]any{
+ "id": float64(22),
+ "title": "Bowling as title",
+ "content": "",
+ "comments": []any{},
+ },
+ map[string]any{
+ "id": float64(33),
+ "title": "Bowl-ing as fuzzy match",
+ "content": "",
+ "comments": []any{},
+ },
+ },
+ }
+ hits, err := convertHits(validResponse)
+ require.NoError(t, err)
+ assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}, {ID: 33}}, hits)
+}
+
+func TestDoubleQuoteKeyword(t *testing.T) {
+ assert.EqualValues(t, "", doubleQuoteKeyword(""))
+ assert.EqualValues(t, `"a" "b" "c"`, doubleQuoteKeyword("a b c"))
+ assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword("a d g"))
+ assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword("a d g"))
+ assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword(`a "" "d" """g`))
+}
diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go
new file mode 100644
index 0000000..e752ae6
--- /dev/null
+++ b/modules/indexer/issues/util.go
@@ -0,0 +1,193 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issues
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/indexer/issues/internal"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/queue"
+)
+
+// getIssueIndexerData returns the indexer data of an issue and a bool value indicating whether the issue exists.
+func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerData, bool, error) {
+ issue, err := issue_model.GetIssueByID(ctx, issueID)
+ if err != nil {
+ if issue_model.IsErrIssueNotExist(err) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+
+ // FIXME: what if users want to search for a review comment of a pull request?
+ // The comment type is CommentTypeCode or CommentTypeReview.
+ // But LoadDiscussComments only loads CommentTypeComment.
+ if err := issue.LoadDiscussComments(ctx); err != nil {
+ return nil, false, err
+ }
+
+ comments := make([]string, 0, len(issue.Comments))
+ for _, comment := range issue.Comments {
+ if comment.Content != "" {
+ // what ever the comment type is, index the content if it is not empty.
+ comments = append(comments, comment.Content)
+ }
+ }
+
+ if err := issue.LoadAttributes(ctx); err != nil {
+ return nil, false, err
+ }
+
+ labels := make([]int64, 0, len(issue.Labels))
+ for _, label := range issue.Labels {
+ labels = append(labels, label.ID)
+ }
+
+ mentionIDs, err := issue_model.GetIssueMentionIDs(ctx, issueID)
+ if err != nil {
+ return nil, false, err
+ }
+
+ var (
+ reviewedIDs []int64
+ reviewRequestedIDs []int64
+ )
+ {
+ reviews, err := issue_model.FindReviews(ctx, issue_model.FindReviewOptions{
+ ListOptions: db.ListOptionsAll,
+ IssueID: issueID,
+ OfficialOnly: false,
+ })
+ if err != nil {
+ return nil, false, err
+ }
+
+ reviewedIDsSet := make(container.Set[int64], len(reviews))
+ reviewRequestedIDsSet := make(container.Set[int64], len(reviews))
+ for _, review := range reviews {
+ if review.Type == issue_model.ReviewTypeRequest {
+ reviewRequestedIDsSet.Add(review.ReviewerID)
+ } else {
+ reviewedIDsSet.Add(review.ReviewerID)
+ }
+ }
+ reviewedIDs = reviewedIDsSet.Values()
+ reviewRequestedIDs = reviewRequestedIDsSet.Values()
+ }
+
+ subscriberIDs, err := issue_model.GetIssueWatchersIDs(ctx, issue.ID, true)
+ if err != nil {
+ return nil, false, err
+ }
+
+ var projectID int64
+ if issue.Project != nil {
+ projectID = issue.Project.ID
+ }
+
+ return &internal.IndexerData{
+ ID: issue.ID,
+ RepoID: issue.RepoID,
+ IsPublic: !issue.Repo.IsPrivate,
+ Title: issue.Title,
+ Content: issue.Content,
+ Comments: comments,
+ IsPull: issue.IsPull,
+ IsClosed: issue.IsClosed,
+ LabelIDs: labels,
+ NoLabel: len(labels) == 0,
+ MilestoneID: issue.MilestoneID,
+ ProjectID: projectID,
+ ProjectColumnID: issue.ProjectColumnID(ctx),
+ PosterID: issue.PosterID,
+ AssigneeID: issue.AssigneeID,
+ MentionIDs: mentionIDs,
+ ReviewedIDs: reviewedIDs,
+ ReviewRequestedIDs: reviewRequestedIDs,
+ SubscriberIDs: subscriberIDs,
+ UpdatedUnix: issue.UpdatedUnix,
+ CreatedUnix: issue.CreatedUnix,
+ DeadlineUnix: issue.DeadlineUnix,
+ CommentCount: int64(len(issue.Comments)),
+ }, true, nil
+}
+
+func updateRepoIndexer(ctx context.Context, repoID int64) error {
+ ids, err := issue_model.GetIssueIDsByRepoID(ctx, repoID)
+ if err != nil {
+ return fmt.Errorf("issue_model.GetIssueIDsByRepoID: %w", err)
+ }
+ for _, id := range ids {
+ if err := updateIssueIndexer(ctx, id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func updateIssueIndexer(ctx context.Context, issueID int64) error {
+ return pushIssueIndexerQueue(ctx, &IndexerMetadata{ID: issueID})
+}
+
+func deleteRepoIssueIndexer(ctx context.Context, repoID int64) error {
+ var ids []int64
+ ids, err := issue_model.GetIssueIDsByRepoID(ctx, repoID)
+ if err != nil {
+ return fmt.Errorf("issue_model.GetIssueIDsByRepoID: %w", err)
+ }
+
+ if len(ids) == 0 {
+ return nil
+ }
+ return pushIssueIndexerQueue(ctx, &IndexerMetadata{
+ IDs: ids,
+ IsDelete: true,
+ })
+}
+
+type keepRetryKey struct{}
+
+// contextWithKeepRetry returns a context with a key indicating that the indexer should keep retrying.
+// Please note that it's for background tasks only, and it should not be used for user requests, or it may cause blocking.
+func contextWithKeepRetry(ctx context.Context) context.Context {
+ return context.WithValue(ctx, keepRetryKey{}, true)
+}
+
+func pushIssueIndexerQueue(ctx context.Context, data *IndexerMetadata) error {
+ if issueIndexerQueue == nil {
+ // Some unit tests will trigger indexing, but the queue is not initialized.
+ // It's OK to ignore it, but log a warning message in case it's not a unit test.
+ log.Warn("Trying to push %+v to issue indexer queue, but the queue is not initialized, it's OK if it's a unit test", data)
+ return nil
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ err := issueIndexerQueue.Push(data)
+ if errors.Is(err, queue.ErrAlreadyInQueue) {
+ return nil
+ }
+ if errors.Is(err, context.DeadlineExceeded) { // the queue is full
+ log.Warn("It seems that issue indexer is slow and the queue is full. Please check the issue indexer or increase the queue size.")
+ if ctx.Value(keepRetryKey{}) == nil {
+ return err
+ }
+ // It will be better to increase the queue size instead of retrying, but users may ignore the previous warning message.
+ // However, even it retries, it may still cause index loss when there's a deadline in the context.
+ log.Debug("Retry to push %+v to issue indexer queue", data)
+ continue
+ }
+ return err
+ }
+}
diff --git a/modules/indexer/stats/db.go b/modules/indexer/stats/db.go
new file mode 100644
index 0000000..98a977c
--- /dev/null
+++ b/modules/indexer/stats/db.go
@@ -0,0 +1,84 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package stats
+
+import (
+ "fmt"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// DBIndexer implements Indexer interface to use database's like search
+type DBIndexer struct{}
+
+// Index repository status function
+func (db *DBIndexer) Index(id int64) error {
+ ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().ShutdownContext(), fmt.Sprintf("Stats.DB Index Repo[%d]", id))
+ defer finished()
+
+ repo, err := repo_model.GetRepositoryByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if repo.IsEmpty {
+ return nil
+ }
+
+ status, err := repo_model.GetIndexerStatus(ctx, repo, repo_model.RepoIndexerTypeStats)
+ if err != nil {
+ return err
+ }
+
+ gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ if err.Error() == "no such file or directory" {
+ return nil
+ }
+ return err
+ }
+ defer gitRepo.Close()
+
+ // Get latest commit for default branch
+ commitID, err := gitRepo.GetBranchCommitID(repo.DefaultBranch)
+ if err != nil {
+ if git.IsErrBranchNotExist(err) || git.IsErrNotExist(err) || setting.IsInTesting {
+ log.Debug("Unable to get commit ID for default branch %s in %s ... skipping this repository", repo.DefaultBranch, repo.RepoPath())
+ return nil
+ }
+ log.Error("Unable to get commit ID for default branch %s in %s. Error: %v", repo.DefaultBranch, repo.RepoPath(), err)
+ return err
+ }
+
+ // Do not recalculate stats if already calculated for this commit
+ if status.CommitSha == commitID {
+ return nil
+ }
+
+ // Calculate and save language statistics to database
+ stats, err := gitRepo.GetLanguageStats(commitID)
+ if err != nil {
+ if !setting.IsInTesting {
+ log.Error("Unable to get language stats for ID %s for default branch %s in %s. Error: %v", commitID, repo.DefaultBranch, repo.RepoPath(), err)
+ }
+ return err
+ }
+ err = repo_model.UpdateLanguageStats(ctx, repo, commitID, stats)
+ if err != nil {
+ log.Error("Unable to update language stats for ID %s for default branch %s in %s. Error: %v", commitID, repo.DefaultBranch, repo.RepoPath(), err)
+ return err
+ }
+
+ log.Debug("DBIndexer completed language stats for ID %s for default branch %s in %s. stats count: %d", commitID, repo.DefaultBranch, repo.RepoPath(), len(stats))
+ return nil
+}
+
+// Close dummy function
+func (db *DBIndexer) Close() {
+}
diff --git a/modules/indexer/stats/indexer.go b/modules/indexer/stats/indexer.go
new file mode 100644
index 0000000..7ec89e2
--- /dev/null
+++ b/modules/indexer/stats/indexer.go
@@ -0,0 +1,88 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package stats
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Indexer defines an interface to index repository stats
+// TODO: this indexer is quite different from the others, maybe this package should be moved out from module/indexer
+type Indexer interface {
+ Index(id int64) error
+ Close()
+}
+
+// indexer represents a indexer instance
+var indexer Indexer
+
+// Init initialize the repo indexer
+func Init() error {
+ indexer = &DBIndexer{}
+
+ if err := initStatsQueue(); err != nil {
+ return err
+ }
+
+ go populateRepoIndexer(db.DefaultContext)
+
+ return nil
+}
+
+// populateRepoIndexer populate the repo indexer with pre-existing data. This
+// should only be run when the indexer is created for the first time.
+func populateRepoIndexer(ctx context.Context) {
+ log.Info("Populating the repo stats indexer with existing repositories")
+
+ isShutdown := graceful.GetManager().IsShutdown()
+
+ exist, err := db.IsTableNotEmpty("repository")
+ if err != nil {
+ log.Fatal("System error: %v", err)
+ } else if !exist {
+ return
+ }
+
+ var maxRepoID int64
+ if maxRepoID, err = db.GetMaxID("repository"); err != nil {
+ log.Fatal("System error: %v", err)
+ }
+
+ // start with the maximum existing repo ID and work backwards, so that we
+ // don't include repos that are created after gitea starts; such repos will
+ // already be added to the indexer, and we don't need to add them again.
+ for maxRepoID > 0 {
+ select {
+ case <-isShutdown:
+ log.Info("Repository Stats Indexer population shutdown before completion")
+ return
+ default:
+ }
+ ids, err := repo_model.GetUnindexedRepos(ctx, repo_model.RepoIndexerTypeStats, maxRepoID, 0, 50)
+ if err != nil {
+ log.Error("populateRepoIndexer: %v", err)
+ return
+ } else if len(ids) == 0 {
+ break
+ }
+ for _, id := range ids {
+ select {
+ case <-isShutdown:
+ log.Info("Repository Stats Indexer population shutdown before completion")
+ return
+ default:
+ }
+ if err := statsQueue.Push(id); err != nil {
+ log.Error("statsQueue.Push: %v", err)
+ }
+ maxRepoID = id - 1
+ }
+ }
+ log.Info("Done (re)populating the repo stats indexer with existing repositories")
+}
diff --git a/modules/indexer/stats/indexer_test.go b/modules/indexer/stats/indexer_test.go
new file mode 100644
index 0000000..3ab2e58
--- /dev/null
+++ b/modules/indexer/stats/indexer_test.go
@@ -0,0 +1,52 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package stats
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func TestRepoStatsIndex(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ setting.CfgProvider, _ = setting.NewConfigProviderFromData("")
+
+ setting.LoadQueueSettings()
+
+ err := Init()
+ require.NoError(t, err)
+
+ repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 1)
+ require.NoError(t, err)
+
+ err = UpdateRepoIndexer(repo)
+ require.NoError(t, err)
+
+ require.NoError(t, queue.GetManager().FlushAll(context.Background(), 5*time.Second))
+
+ status, err := repo_model.GetIndexerStatus(db.DefaultContext, repo, repo_model.RepoIndexerTypeStats)
+ require.NoError(t, err)
+ assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", status.CommitSha)
+ langs, err := repo_model.GetTopLanguageStats(db.DefaultContext, repo, 5)
+ require.NoError(t, err)
+ assert.Empty(t, langs)
+}
diff --git a/modules/indexer/stats/queue.go b/modules/indexer/stats/queue.go
new file mode 100644
index 0000000..d002bd5
--- /dev/null
+++ b/modules/indexer/stats/queue.go
@@ -0,0 +1,49 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package stats
+
+import (
+ "fmt"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// statsQueue represents a queue to handle repository stats updates
+var statsQueue *queue.WorkerPoolQueue[int64]
+
+// handle passed PR IDs and test the PRs
+func handler(items ...int64) []int64 {
+ for _, opts := range items {
+ if err := indexer.Index(opts); err != nil {
+ if !setting.IsInTesting {
+ log.Error("stats queue indexer.Index(%d) failed: %v", opts, err)
+ }
+ }
+ }
+ return nil
+}
+
+func initStatsQueue() error {
+ statsQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "repo_stats_update", handler)
+ if statsQueue == nil {
+ return fmt.Errorf("unable to create repo_stats_update queue")
+ }
+ go graceful.GetManager().RunWithCancel(statsQueue)
+ return nil
+}
+
+// UpdateRepoIndexer update a repository's entries in the indexer
+func UpdateRepoIndexer(repo *repo_model.Repository) error {
+ if err := statsQueue.Push(repo.ID); err != nil {
+ if err != queue.ErrAlreadyInQueue {
+ return err
+ }
+ log.Debug("Repo ID: %d already queued", repo.ID)
+ }
+ return nil
+}
diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go
new file mode 100644
index 0000000..967bed0
--- /dev/null
+++ b/modules/issue/template/template.go
@@ -0,0 +1,489 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package template
+
+import (
+ "fmt"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/container"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "gitea.com/go-chi/binding"
+)
+
+// Validate checks whether an IssueTemplate is considered valid, and returns the first error
+func Validate(template *api.IssueTemplate) error {
+ if err := validateMetadata(template); err != nil {
+ return err
+ }
+ if template.Type() == api.IssueTemplateTypeYaml {
+ if err := validateYaml(template); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateMetadata(template *api.IssueTemplate) error {
+ if strings.TrimSpace(template.Name) == "" {
+ return fmt.Errorf("'name' is required")
+ }
+ if strings.TrimSpace(template.About) == "" {
+ return fmt.Errorf("'about' is required")
+ }
+ return nil
+}
+
+func validateYaml(template *api.IssueTemplate) error {
+ if len(template.Fields) == 0 {
+ return fmt.Errorf("'body' is required")
+ }
+ ids := make(container.Set[string])
+ for idx, field := range template.Fields {
+ if err := validateID(field, idx, ids); err != nil {
+ return err
+ }
+ if err := validateLabel(field, idx); err != nil {
+ return err
+ }
+
+ position := newErrorPosition(idx, field.Type)
+ switch field.Type {
+ case api.IssueFormFieldTypeMarkdown:
+ if err := validateStringItem(position, field.Attributes, true, "value"); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeTextarea:
+ if err := validateStringItem(position, field.Attributes, false,
+ "description",
+ "placeholder",
+ "value",
+ "render",
+ ); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeInput:
+ if err := validateStringItem(position, field.Attributes, false,
+ "description",
+ "placeholder",
+ "value",
+ ); err != nil {
+ return err
+ }
+ if err := validateBoolItem(position, field.Validations, "is_number"); err != nil {
+ return err
+ }
+ if err := validateStringItem(position, field.Validations, false, "regex"); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeDropdown:
+ if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
+ return err
+ }
+ if err := validateBoolItem(position, field.Attributes, "multiple"); err != nil {
+ return err
+ }
+ if err := validateBoolItem(position, field.Attributes, "list"); err != nil {
+ return err
+ }
+ if err := validateOptions(field, idx); err != nil {
+ return err
+ }
+ if err := validateDropdownDefault(position, field.Attributes); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
+ return err
+ }
+ if err := validateOptions(field, idx); err != nil {
+ return err
+ }
+ default:
+ return position.Errorf("unknown type")
+ }
+
+ if err := validateRequired(field, idx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateLabel(field *api.IssueFormField, idx int) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown {
+ // The label is not required for a markdown field
+ return nil
+ }
+ return validateStringItem(newErrorPosition(idx, field.Type), field.Attributes, true, "label")
+}
+
+func validateRequired(field *api.IssueFormField, idx int) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown || field.Type == api.IssueFormFieldTypeCheckboxes {
+ // The label is not required for a markdown or checkboxes field
+ return nil
+ }
+ if err := validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required"); err != nil {
+ return err
+ }
+ if required, _ := field.Validations["required"].(bool); required && !field.VisibleOnForm() {
+ return newErrorPosition(idx, field.Type).Errorf("can not require a hidden field")
+ }
+ return nil
+}
+
+func validateID(field *api.IssueFormField, idx int, ids container.Set[string]) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown {
+ // The ID is not required for a markdown field
+ return nil
+ }
+
+ position := newErrorPosition(idx, field.Type)
+ if field.ID == "" {
+ // If the ID is empty in yaml, template.Unmarshal will auto autofill it, so it cannot be empty
+ return position.Errorf("'id' is required")
+ }
+ if binding.AlphaDashPattern.MatchString(field.ID) {
+ return position.Errorf("'id' should contain only alphanumeric, '-' and '_'")
+ }
+ if !ids.Add(field.ID) {
+ return position.Errorf("'id' should be unique")
+ }
+ return nil
+}
+
+func validateOptions(field *api.IssueFormField, idx int) error {
+ if field.Type != api.IssueFormFieldTypeDropdown && field.Type != api.IssueFormFieldTypeCheckboxes {
+ return nil
+ }
+ position := newErrorPosition(idx, field.Type)
+
+ options, ok := field.Attributes["options"].([]any)
+ if !ok || len(options) == 0 {
+ return position.Errorf("'options' is required and should be a array")
+ }
+
+ for optIdx, option := range options {
+ position := newErrorPosition(idx, field.Type, optIdx)
+ switch field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ if _, ok := option.(string); !ok {
+ return position.Errorf("should be a string")
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ opt, ok := option.(map[string]any)
+ if !ok {
+ return position.Errorf("should be a dictionary")
+ }
+ if label, ok := opt["label"].(string); !ok || label == "" {
+ return position.Errorf("'label' is required and should be a string")
+ }
+
+ if visibility, ok := opt["visible"]; ok {
+ visibilityList, ok := visibility.([]any)
+ if !ok {
+ return position.Errorf("'visible' should be list")
+ }
+ for _, visibleType := range visibilityList {
+ visibleType, ok := visibleType.(string)
+ if !ok || !(visibleType == "form" || visibleType == "content") {
+ return position.Errorf("'visible' list can only contain strings of 'form' and 'content'")
+ }
+ }
+ }
+
+ if required, ok := opt["required"]; ok {
+ if _, ok := required.(bool); !ok {
+ return position.Errorf("'required' should be a bool")
+ }
+
+ // validate if hidden field is required
+ if visibility, ok := opt["visible"]; ok {
+ visibilityList, _ := visibility.([]any)
+ isVisible := false
+ for _, v := range visibilityList {
+ if vv, _ := v.(string); vv == "form" {
+ isVisible = true
+ break
+ }
+ }
+ if !isVisible {
+ return position.Errorf("can not require a hidden checkbox")
+ }
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func validateStringItem(position errorPosition, m map[string]any, required bool, names ...string) error {
+ for _, name := range names {
+ v, ok := m[name]
+ if !ok {
+ if required {
+ return position.Errorf("'%s' is required", name)
+ }
+ return nil
+ }
+ attr, ok := v.(string)
+ if !ok {
+ return position.Errorf("'%s' should be a string", name)
+ }
+ if strings.TrimSpace(attr) == "" && required {
+ return position.Errorf("'%s' is required", name)
+ }
+ }
+ return nil
+}
+
+func validateBoolItem(position errorPosition, m map[string]any, names ...string) error {
+ for _, name := range names {
+ v, ok := m[name]
+ if !ok {
+ return nil
+ }
+ if _, ok := v.(bool); !ok {
+ return position.Errorf("'%s' should be a bool", name)
+ }
+ }
+ return nil
+}
+
+func validateDropdownDefault(position errorPosition, attributes map[string]any) error {
+ v, ok := attributes["default"]
+ if !ok {
+ return nil
+ }
+ defaultValue, ok := v.(int)
+ if !ok {
+ return position.Errorf("'default' should be an int")
+ }
+
+ options, ok := attributes["options"].([]any)
+ if !ok {
+ // should not happen
+ return position.Errorf("'options' is required and should be a array")
+ }
+ if defaultValue < 0 || defaultValue >= len(options) {
+ return position.Errorf("the value of 'default' is out of range")
+ }
+
+ return nil
+}
+
+type errorPosition string
+
+func (p errorPosition) Errorf(format string, a ...any) error {
+ return fmt.Errorf(string(p)+": "+format, a...)
+}
+
+func newErrorPosition(fieldIdx int, fieldType api.IssueFormFieldType, optionIndex ...int) errorPosition {
+ ret := fmt.Sprintf("body[%d](%s)", fieldIdx, fieldType)
+ if len(optionIndex) > 0 {
+ ret += fmt.Sprintf(", option[%d]", optionIndex[0])
+ }
+ return errorPosition(ret)
+}
+
+// RenderToMarkdown renders template to markdown with specified values
+func RenderToMarkdown(template *api.IssueTemplate, values url.Values) string {
+ builder := &strings.Builder{}
+
+ for _, field := range template.Fields {
+ f := &valuedField{
+ IssueFormField: field,
+ Values: values,
+ }
+ if f.ID == "" || !f.VisibleInContent() {
+ continue
+ }
+ f.WriteTo(builder)
+ }
+
+ return builder.String()
+}
+
+type valuedField struct {
+ *api.IssueFormField
+ url.Values
+}
+
+func (f *valuedField) WriteTo(builder *strings.Builder) {
+ // write label
+ if !f.HideLabel() {
+ _, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label())
+ }
+
+ blankPlaceholder := "_No response_\n"
+
+ // write body
+ switch f.Type {
+ case api.IssueFormFieldTypeCheckboxes:
+ for _, option := range f.Options() {
+ if !option.VisibleInContent() {
+ continue
+ }
+ checked := " "
+ if option.IsChecked() {
+ checked = "x"
+ }
+ _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label())
+ }
+ case api.IssueFormFieldTypeDropdown:
+ var checkeds []string
+ for _, option := range f.Options() {
+ if option.IsChecked() {
+ checkeds = append(checkeds, option.Label())
+ }
+ }
+ if len(checkeds) > 0 {
+ if list, ok := f.Attributes["list"].(bool); ok && list {
+ for _, check := range checkeds {
+ _, _ = fmt.Fprintf(builder, "- %s\n", check)
+ }
+ } else {
+ _, _ = fmt.Fprintf(builder, "%s\n", strings.Join(checkeds, ", "))
+ }
+ } else {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ }
+ case api.IssueFormFieldTypeInput:
+ if value := f.Value(); value == "" {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ } else {
+ _, _ = fmt.Fprintf(builder, "%s\n", value)
+ }
+ case api.IssueFormFieldTypeTextarea:
+ if value := f.Value(); value == "" {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ } else if render := f.Render(); render != "" {
+ quotes := minQuotes(value)
+ _, _ = fmt.Fprintf(builder, "%s%s\n%s\n%s\n", quotes, f.Render(), value, quotes)
+ } else {
+ _, _ = fmt.Fprintf(builder, "%s\n", value)
+ }
+ case api.IssueFormFieldTypeMarkdown:
+ if value, ok := f.Attributes["value"].(string); ok {
+ _, _ = fmt.Fprintf(builder, "%s\n", value)
+ }
+ }
+ _, _ = fmt.Fprintln(builder)
+}
+
+func (f *valuedField) Label() string {
+ if label, ok := f.Attributes["label"].(string); ok {
+ return label
+ }
+ return ""
+}
+
+func (f *valuedField) HideLabel() bool {
+ if f.Type == api.IssueFormFieldTypeMarkdown {
+ return true
+ }
+ if label, ok := f.Attributes["hide_label"].(bool); ok {
+ return label
+ }
+ return false
+}
+
+func (f *valuedField) Render() string {
+ if render, ok := f.Attributes["render"].(string); ok {
+ return render
+ }
+ return ""
+}
+
+func (f *valuedField) Value() string {
+ return strings.TrimSpace(f.Get("form-field-" + f.ID))
+}
+
+func (f *valuedField) Options() []*valuedOption {
+ if options, ok := f.Attributes["options"].([]any); ok {
+ ret := make([]*valuedOption, 0, len(options))
+ for i, option := range options {
+ ret = append(ret, &valuedOption{
+ index: i,
+ data: option,
+ field: f,
+ })
+ }
+ return ret
+ }
+ return nil
+}
+
+type valuedOption struct {
+ index int
+ data any
+ field *valuedField
+}
+
+func (o *valuedOption) Label() string {
+ switch o.field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ if label, ok := o.data.(string); ok {
+ return label
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ if vs, ok := o.data.(map[string]any); ok {
+ if v, ok := vs["label"].(string); ok {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func (o *valuedOption) IsChecked() bool {
+ switch o.field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ checks := strings.Split(o.field.Get(fmt.Sprintf("form-field-%s", o.field.ID)), ",")
+ idx := strconv.Itoa(o.index)
+ for _, v := range checks {
+ if v == idx {
+ return true
+ }
+ }
+ return false
+ case api.IssueFormFieldTypeCheckboxes:
+ return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on"
+ }
+ return false
+}
+
+func (o *valuedOption) VisibleInContent() bool {
+ if o.field.Type == api.IssueFormFieldTypeCheckboxes {
+ if vs, ok := o.data.(map[string]any); ok {
+ if vl, ok := vs["visible"].([]any); ok {
+ for _, v := range vl {
+ if vv, _ := v.(string); vv == "content" {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ }
+ return true
+}
+
+var minQuotesRegex = regexp.MustCompilePOSIX("^`{3,}")
+
+// minQuotes return 3 or more back-quotes.
+// If n back-quotes exists, use n+1 back-quotes to quote.
+func minQuotes(value string) string {
+ ret := "```"
+ for _, v := range minQuotesRegex.FindAllString(value, -1) {
+ if len(v) >= len(ret) {
+ ret = v + "`"
+ }
+ }
+ return ret
+}
diff --git a/modules/issue/template/template_test.go b/modules/issue/template/template_test.go
new file mode 100644
index 0000000..349dbea
--- /dev/null
+++ b/modules/issue/template/template_test.go
@@ -0,0 +1,963 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package template
+
+import (
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ filename string
+ content string
+ want *api.IssueTemplate
+ wantErr string
+ }{
+ {
+ name: "miss name",
+ content: ``,
+ wantErr: "'name' is required",
+ },
+ {
+ name: "miss about",
+ content: `
+name: "test"
+`,
+ wantErr: "'about' is required",
+ },
+ {
+ name: "miss body",
+ content: `
+name: "test"
+about: "this is about"
+`,
+ wantErr: "'body' is required",
+ },
+ {
+ name: "markdown miss value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+`,
+ wantErr: "body[0](markdown): 'value' is required",
+ },
+ {
+ name: "markdown invalid value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+ attributes:
+ value: true
+`,
+ wantErr: "body[0](markdown): 'value' should be a string",
+ },
+ {
+ name: "markdown empty value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+ attributes:
+ value: ""
+`,
+ wantErr: "body[0](markdown): 'value' is required",
+ },
+ {
+ name: "textarea invalid id",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "?"
+`,
+ wantErr: "body[0](textarea): 'id' should contain only alphanumeric, '-' and '_'",
+ },
+ {
+ name: "textarea miss label",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+`,
+ wantErr: "body[0](textarea): 'label' is required",
+ },
+ {
+ name: "textarea conflict id",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "b"
+`,
+ wantErr: "body[1](textarea): 'id' should be unique",
+ },
+ {
+ name: "textarea invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](textarea): 'description' should be a string",
+ },
+ {
+ name: "textarea invalid required",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ required: "on"
+`,
+ wantErr: "body[0](textarea): 'required' should be a bool",
+ },
+ {
+ name: "input invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](input): 'description' should be a string",
+ },
+ {
+ name: "input invalid is_number",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ is_number: "yes"
+`,
+ wantErr: "body[0](input): 'is_number' should be a bool",
+ },
+ {
+ name: "input invalid regex",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ regex: true
+`,
+ wantErr: "body[0](input): 'regex' should be a string",
+ },
+ {
+ name: "dropdown invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](dropdown): 'description' should be a string",
+ },
+ {
+ name: "dropdown invalid multiple",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ multiple: "on"
+`,
+ wantErr: "body[0](dropdown): 'multiple' should be a bool",
+ },
+ {
+ name: "dropdown invalid list",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ list: "on"
+`,
+ wantErr: "body[0](dropdown): 'list' should be a bool",
+ },
+ {
+ name: "checkboxes invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](checkboxes): 'description' should be a string",
+ },
+ {
+ name: "invalid type",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "video"
+ id: "1"
+ attributes:
+ label: "a"
+`,
+ wantErr: "body[0](video): unknown type",
+ },
+ {
+ name: "dropdown miss options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+`,
+ wantErr: "body[0](dropdown): 'options' is required and should be a array",
+ },
+ {
+ name: "dropdown invalid options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - "a"
+ - true
+`,
+ wantErr: "body[0](dropdown), option[1]: should be a string",
+ },
+ {
+ name: "checkboxes invalid options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - "a"
+ - true
+`,
+ wantErr: "body[0](checkboxes), option[0]: should be a dictionary",
+ },
+ {
+ name: "checkboxes option miss label",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - required: true
+`,
+ wantErr: "body[0](checkboxes), option[0]: 'label' is required and should be a string",
+ },
+ {
+ name: "checkboxes option invalid required",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - label: "a"
+ required: "on"
+`,
+ wantErr: "body[0](checkboxes), option[0]: 'required' should be a bool",
+ },
+ {
+ name: "field is required but hidden",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ required: true
+ visible: [content]
+`,
+ wantErr: "body[0](input): can not require a hidden field",
+ },
+ {
+ name: "checkboxes is required but hidden",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: checkboxes
+ id: "1"
+ attributes:
+ label: Label of checkboxes
+ description: Description of checkboxes
+ options:
+ - label: Option 1
+ required: false
+ - label: Required and hidden
+ required: true
+ visible: [content]
+`,
+ wantErr: "body[0](checkboxes), option[1]: can not require a hidden checkbox",
+ },
+ {
+ name: "dropdown default is not an integer",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: dropdown
+ id: "1"
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ default: "def"
+ validations:
+ required: true
+`,
+ wantErr: "body[0](dropdown): 'default' should be an int",
+ },
+ {
+ name: "dropdown default is out of range",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: dropdown
+ id: "1"
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ default: 3
+ validations:
+ required: true
+`,
+ wantErr: "body[0](dropdown): the value of 'default' is out of range",
+ },
+ {
+ name: "dropdown without default is valid",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: dropdown
+ id: "1"
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ validations:
+ required: true
+`,
+ want: &api.IssueTemplate{
+ Name: "test",
+ About: "this is about",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "dropdown",
+ ID: "1",
+ Attributes: map[string]any{
+ "label": "Label of dropdown",
+ "description": "Description of dropdown",
+ "multiple": true,
+ "options": []any{
+ "Option 1 of dropdown",
+ "Option 2 of dropdown",
+ "Option 3 of dropdown",
+ },
+ },
+ Validations: map[string]any{
+ "required": true,
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
+ },
+ },
+ FileName: "test.yaml",
+ },
+ wantErr: "",
+ },
+ {
+ name: "valid",
+ content: `
+name: Name
+title: Title
+about: About
+labels: ["label1", "label2"]
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown
+ - type: textarea
+ id: id2
+ attributes:
+ label: Label of textarea
+ description: Description of textarea
+ placeholder: Placeholder of textarea
+ value: Value of textarea
+ render: bash
+ validations:
+ required: true
+ - type: input
+ id: id3
+ attributes:
+ label: Label of input
+ description: Description of input
+ placeholder: Placeholder of input
+ value: Value of input
+ validations:
+ required: true
+ is_number: true
+ regex: "[a-zA-Z0-9]+"
+ - type: dropdown
+ id: id4
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ default: 1
+ validations:
+ required: true
+ - type: checkboxes
+ id: id5
+ attributes:
+ label: Label of checkboxes
+ description: Description of checkboxes
+ options:
+ - label: Option 1 of checkboxes
+ required: true
+ - label: Option 2 of checkboxes
+ required: false
+ - label: Hidden Option 3 of checkboxes
+ visible: [content]
+ - label: Required but not submitted
+ required: true
+ visible: [form]
+`,
+ want: &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: []string{"label1", "label2"},
+ Ref: "Ref",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "markdown",
+ ID: "id1",
+ Attributes: map[string]any{
+ "value": "Value of the markdown",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
+ },
+ {
+ Type: "textarea",
+ ID: "id2",
+ Attributes: map[string]any{
+ "label": "Label of textarea",
+ "description": "Description of textarea",
+ "placeholder": "Placeholder of textarea",
+ "value": "Value of textarea",
+ "render": "bash",
+ },
+ Validations: map[string]any{
+ "required": true,
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
+ },
+ {
+ Type: "input",
+ ID: "id3",
+ Attributes: map[string]any{
+ "label": "Label of input",
+ "description": "Description of input",
+ "placeholder": "Placeholder of input",
+ "value": "Value of input",
+ },
+ Validations: map[string]any{
+ "required": true,
+ "is_number": true,
+ "regex": "[a-zA-Z0-9]+",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
+ },
+ {
+ Type: "dropdown",
+ ID: "id4",
+ Attributes: map[string]any{
+ "label": "Label of dropdown",
+ "description": "Description of dropdown",
+ "multiple": true,
+ "options": []any{
+ "Option 1 of dropdown",
+ "Option 2 of dropdown",
+ "Option 3 of dropdown",
+ },
+ "default": 1,
+ },
+ Validations: map[string]any{
+ "required": true,
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
+ },
+ {
+ Type: "checkboxes",
+ ID: "id5",
+ Attributes: map[string]any{
+ "label": "Label of checkboxes",
+ "description": "Description of checkboxes",
+ "options": []any{
+ map[string]any{"label": "Option 1 of checkboxes", "required": true},
+ map[string]any{"label": "Option 2 of checkboxes", "required": false},
+ map[string]any{"label": "Hidden Option 3 of checkboxes", "visible": []string{"content"}},
+ map[string]any{"label": "Required but not submitted", "required": true, "visible": []string{"form"}},
+ },
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
+ },
+ },
+ FileName: "test.yaml",
+ },
+ wantErr: "",
+ },
+ {
+ name: "single label",
+ content: `
+name: Name
+title: Title
+about: About
+labels: label1
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown shown in form
+ - type: markdown
+ id: id2
+ attributes:
+ value: Value of the markdown shown in created issue
+ visible: [content]
+`,
+ want: &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: []string{"label1"},
+ Ref: "Ref",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "markdown",
+ ID: "id1",
+ Attributes: map[string]any{
+ "value": "Value of the markdown shown in form",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
+ },
+ {
+ Type: "markdown",
+ ID: "id2",
+ Attributes: map[string]any{
+ "value": "Value of the markdown shown in created issue",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleContent},
+ },
+ },
+ FileName: "test.yaml",
+ },
+ wantErr: "",
+ },
+ {
+ name: "comma-delimited labels",
+ content: `
+name: Name
+title: Title
+about: About
+labels: label1,label2,,label3 ,,
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown
+`,
+ want: &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: []string{"label1", "label2", "label3"},
+ Ref: "Ref",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "markdown",
+ ID: "id1",
+ Attributes: map[string]any{
+ "value": "Value of the markdown",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
+ },
+ },
+ FileName: "test.yaml",
+ },
+ wantErr: "",
+ },
+ {
+ name: "empty string as labels",
+ content: `
+name: Name
+title: Title
+about: About
+labels: ''
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown
+`,
+ want: &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: nil,
+ Ref: "Ref",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "markdown",
+ ID: "id1",
+ Attributes: map[string]any{
+ "value": "Value of the markdown",
+ },
+ Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
+ },
+ },
+ FileName: "test.yaml",
+ },
+ wantErr: "",
+ },
+ {
+ name: "comma delimited labels in markdown",
+ filename: "test.md",
+ content: `---
+name: Name
+title: Title
+about: About
+labels: label1,label2,,label3 ,,
+ref: Ref
+---
+Content
+`,
+ want: &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: []string{"label1", "label2", "label3"},
+ Ref: "Ref",
+ Fields: nil,
+ Content: "Content\n",
+ FileName: "test.md",
+ },
+ wantErr: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ filename := "test.yaml"
+ if tt.filename != "" {
+ filename = tt.filename
+ }
+ tmpl, err := unmarshal(filename, []byte(tt.content))
+ require.NoError(t, err)
+ if tt.wantErr != "" {
+ require.EqualError(t, Validate(tmpl), tt.wantErr)
+ } else {
+ require.NoError(t, Validate(tmpl))
+ want, _ := json.Marshal(tt.want)
+ got, _ := json.Marshal(tmpl)
+ require.JSONEq(t, string(want), string(got))
+ }
+ })
+ }
+}
+
+func TestRenderToMarkdown(t *testing.T) {
+ type args struct {
+ template string
+ values url.Values
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ name: "normal",
+ args: args{
+ template: `
+name: Name
+title: Title
+about: About
+labels: ["label1", "label2"]
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown shown in form
+ - type: markdown
+ id: id2
+ attributes:
+ value: Value of the markdown shown in created issue
+ visible: [content]
+ - type: textarea
+ id: id3
+ attributes:
+ label: Label of textarea
+ description: Description of textarea
+ placeholder: Placeholder of textarea
+ value: Value of textarea
+ render: bash
+ validations:
+ required: true
+ - type: input
+ id: id4
+ attributes:
+ label: Label of input
+ description: Description of input
+ placeholder: Placeholder of input
+ value: Value of input
+ hide_label: true
+ validations:
+ required: true
+ is_number: true
+ regex: "[a-zA-Z0-9]+"
+ - type: dropdown
+ id: id5
+ attributes:
+ label: Label of dropdown (one line)
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ validations:
+ required: true
+ - type: dropdown
+ id: id6
+ attributes:
+ label: Label of dropdown (list)
+ description: Description of dropdown
+ multiple: true
+ list: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ validations:
+ required: true
+ - type: checkboxes
+ id: id7
+ attributes:
+ label: Label of checkboxes
+ description: Description of checkboxes
+ options:
+ - label: Option 1 of checkboxes
+ required: true
+ - label: Option 2 of checkboxes
+ required: false
+ - label: Option 3 of checkboxes
+ required: true
+ visible: [form]
+ - label: Hidden Option of checkboxes
+ visible: [content]
+`,
+ values: map[string][]string{
+ "form-field-id3": {"Value of id3"},
+ "form-field-id4": {"Value of id4"},
+ "form-field-id5": {"0,1"},
+ "form-field-id6": {"1,2"},
+ "form-field-id7-0": {"on"},
+ "form-field-id7-2": {"on"},
+ },
+ },
+
+ want: `Value of the markdown shown in created issue
+
+### Label of textarea
+
+` + "```bash\nValue of id3\n```" + `
+
+Value of id4
+
+### Label of dropdown (one line)
+
+Option 1 of dropdown, Option 2 of dropdown
+
+### Label of dropdown (list)
+
+- Option 2 of dropdown
+- Option 3 of dropdown
+
+### Label of checkboxes
+
+- [x] Option 1 of checkboxes
+- [ ] Option 2 of checkboxes
+- [ ] Hidden Option of checkboxes
+
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ template, err := Unmarshal("test.yaml", []byte(tt.args.template))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := RenderToMarkdown(template, tt.args.values); got != tt.want {
+ assert.EqualValues(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func Test_minQuotes(t *testing.T) {
+ type args struct {
+ value string
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ name: "without quote",
+ args: args{
+ value: "Hello\nWorld",
+ },
+ want: "```",
+ },
+ {
+ name: "with 1 quote",
+ args: args{
+ value: "Hello\nWorld\n`text`\n",
+ },
+ want: "```",
+ },
+ {
+ name: "with 3 quotes",
+ args: args{
+ value: "Hello\nWorld\n`text`\n```go\ntext\n```\n",
+ },
+ want: "````",
+ },
+ {
+ name: "with more quotes",
+ args: args{
+ value: "Hello\nWorld\n`text`\n```go\ntext\n```\n``````````bash\ntext\n``````````\n",
+ },
+ want: "```````````",
+ },
+ {
+ name: "not leading quotes",
+ args: args{
+ value: "Hello\nWorld`text````go\ntext`````````````bash\ntext``````````\n",
+ },
+ want: "```",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := minQuotes(tt.args.value); got != tt.want {
+ t.Errorf("minQuotes() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go
new file mode 100644
index 0000000..0fc13d7
--- /dev/null
+++ b/modules/issue/template/unmarshal.go
@@ -0,0 +1,147 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package template
+
+import (
+ "fmt"
+ "io"
+ "path"
+ "strconv"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+
+ "gopkg.in/yaml.v3"
+)
+
+// CouldBe indicates a file with the filename could be a template,
+// it is a low cost check before further processing.
+func CouldBe(filename string) bool {
+ it := &api.IssueTemplate{
+ FileName: filename,
+ }
+ return it.Type() != ""
+}
+
+// Unmarshal parses out a valid template from the content
+func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) {
+ it, err := unmarshal(filename, content)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := Validate(it); err != nil {
+ return nil, err
+ }
+
+ return it, nil
+}
+
+// UnmarshalFromEntry parses out a valid template from the blob in entry
+func UnmarshalFromEntry(entry *git.TreeEntry, dir string) (*api.IssueTemplate, error) {
+ return unmarshalFromEntry(entry, path.Join(dir, entry.Name())) // Filepaths in Git are ALWAYS '/' separated do not use filepath here
+}
+
+// UnmarshalFromCommit parses out a valid template from the commit
+func UnmarshalFromCommit(commit *git.Commit, filename string) (*api.IssueTemplate, error) {
+ entry, err := commit.GetTreeEntryByPath(filename)
+ if err != nil {
+ return nil, fmt.Errorf("get entry for %q: %w", filename, err)
+ }
+ return unmarshalFromEntry(entry, filename)
+}
+
+// UnmarshalFromRepo parses out a valid template from the head commit of the branch
+func UnmarshalFromRepo(repo *git.Repository, branch, filename string) (*api.IssueTemplate, error) {
+ commit, err := repo.GetBranchCommit(branch)
+ if err != nil {
+ return nil, fmt.Errorf("get commit on branch %q: %w", branch, err)
+ }
+
+ return UnmarshalFromCommit(commit, filename)
+}
+
+func unmarshalFromEntry(entry *git.TreeEntry, filename string) (*api.IssueTemplate, error) {
+ if size := entry.Blob().Size(); size > setting.UI.MaxDisplayFileSize {
+ return nil, fmt.Errorf("too large: %v > MaxDisplayFileSize", size)
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil, fmt.Errorf("data async: %w", err)
+ }
+ defer r.Close()
+
+ content, err := io.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("read all: %w", err)
+ }
+
+ return Unmarshal(filename, content)
+}
+
+func unmarshal(filename string, content []byte) (*api.IssueTemplate, error) {
+ it := &api.IssueTemplate{
+ FileName: filename,
+ }
+
+ // Compatible with treating description as about
+ compatibleTemplate := &struct {
+ About string `yaml:"description"`
+ }{}
+
+ if typ := it.Type(); typ == api.IssueTemplateTypeMarkdown {
+ if templateBody, err := markdown.ExtractMetadata(string(content), it); err != nil {
+ // The only thing we know here is that we can't extract metadata from the content,
+ // it's hard to tell if metadata doesn't exist or metadata isn't valid.
+ // There's an example template:
+ //
+ // ---
+ // # Title
+ // ---
+ // Content
+ //
+ // It could be a valid markdown with two horizontal lines, or an invalid markdown with wrong metadata.
+
+ it.Content = string(content)
+ it.Name = path.Base(it.FileName) // paths in Git are always '/' separated - do not use filepath!
+ it.About, _ = util.SplitStringAtByteN(it.Content, 80)
+ } else {
+ it.Content = templateBody
+ if it.About == "" {
+ if _, err := markdown.ExtractMetadata(string(content), compatibleTemplate); err == nil && compatibleTemplate.About != "" {
+ it.About = compatibleTemplate.About
+ }
+ }
+ }
+ } else if typ == api.IssueTemplateTypeYaml {
+ if err := yaml.Unmarshal(content, it); err != nil {
+ return nil, fmt.Errorf("yaml unmarshal: %w", err)
+ }
+ if it.About == "" {
+ if err := yaml.Unmarshal(content, compatibleTemplate); err == nil && compatibleTemplate.About != "" {
+ it.About = compatibleTemplate.About
+ }
+ }
+ for i, v := range it.Fields {
+ // set default id value
+ if v.ID == "" {
+ v.ID = strconv.Itoa(i)
+ }
+ // set default visibility
+ if v.Visible == nil {
+ v.Visible = []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm}
+ // markdown is not submitted by default
+ if v.Type != api.IssueFormFieldTypeMarkdown {
+ v.Visible = append(v.Visible, api.IssueFormFieldVisibleContent)
+ }
+ }
+ }
+ }
+
+ return it, nil
+}
diff --git a/modules/json/json.go b/modules/json/json.go
new file mode 100644
index 0000000..34568c7
--- /dev/null
+++ b/modules/json/json.go
@@ -0,0 +1,172 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package json
+
+// Allow "encoding/json" import.
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json" //nolint:depguard
+ "io"
+
+ jsoniter "github.com/json-iterator/go"
+)
+
+// Encoder represents an encoder for json
+type Encoder interface {
+ Encode(v any) error
+}
+
+// Decoder represents a decoder for json
+type Decoder interface {
+ Decode(v any) error
+}
+
+// Interface represents an interface to handle json data
+type Interface interface {
+ Marshal(v any) ([]byte, error)
+ Unmarshal(data []byte, v any) error
+ NewEncoder(writer io.Writer) Encoder
+ NewDecoder(reader io.Reader) Decoder
+ Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error
+}
+
+var (
+ // DefaultJSONHandler default json handler
+ DefaultJSONHandler Interface = JSONiter{jsoniter.ConfigCompatibleWithStandardLibrary}
+
+ _ Interface = StdJSON{}
+ _ Interface = JSONiter{}
+)
+
+// StdJSON implements Interface via encoding/json
+type StdJSON struct{}
+
+// Marshal implements Interface
+func (StdJSON) Marshal(v any) ([]byte, error) {
+ return json.Marshal(v)
+}
+
+// Unmarshal implements Interface
+func (StdJSON) Unmarshal(data []byte, v any) error {
+ return json.Unmarshal(data, v)
+}
+
+// NewEncoder implements Interface
+func (StdJSON) NewEncoder(writer io.Writer) Encoder {
+ return json.NewEncoder(writer)
+}
+
+// NewDecoder implements Interface
+func (StdJSON) NewDecoder(reader io.Reader) Decoder {
+ return json.NewDecoder(reader)
+}
+
+// Indent implements Interface
+func (StdJSON) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
+ return json.Indent(dst, src, prefix, indent)
+}
+
+// JSONiter implements Interface via jsoniter
+type JSONiter struct {
+ jsoniter.API
+}
+
+// Marshal implements Interface
+func (j JSONiter) Marshal(v any) ([]byte, error) {
+ return j.API.Marshal(v)
+}
+
+// Unmarshal implements Interface
+func (j JSONiter) Unmarshal(data []byte, v any) error {
+ return j.API.Unmarshal(data, v)
+}
+
+// NewEncoder implements Interface
+func (j JSONiter) NewEncoder(writer io.Writer) Encoder {
+ return j.API.NewEncoder(writer)
+}
+
+// NewDecoder implements Interface
+func (j JSONiter) NewDecoder(reader io.Reader) Decoder {
+ return j.API.NewDecoder(reader)
+}
+
+// Indent implements Interface, since jsoniter don't support Indent, just use encoding/json's
+func (j JSONiter) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
+ return json.Indent(dst, src, prefix, indent)
+}
+
+// Marshal converts object as bytes
+func Marshal(v any) ([]byte, error) {
+ return DefaultJSONHandler.Marshal(v)
+}
+
+// Unmarshal decodes object from bytes
+func Unmarshal(data []byte, v any) error {
+ return DefaultJSONHandler.Unmarshal(data, v)
+}
+
+// NewEncoder creates an encoder to write objects to writer
+func NewEncoder(writer io.Writer) Encoder {
+ return DefaultJSONHandler.NewEncoder(writer)
+}
+
+// NewDecoder creates a decoder to read objects from reader
+func NewDecoder(reader io.Reader) Decoder {
+ return DefaultJSONHandler.NewDecoder(reader)
+}
+
+// Indent appends to dst an indented form of the JSON-encoded src.
+func Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
+ return DefaultJSONHandler.Indent(dst, src, prefix, indent)
+}
+
+// MarshalIndent copied from encoding/json
+func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
+ b, err := Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ var buf bytes.Buffer
+ err = Indent(&buf, b, prefix, indent)
+ if err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// Valid proxy to json.Valid
+func Valid(data []byte) bool {
+ return json.Valid(data)
+}
+
+// UnmarshalHandleDoubleEncode - due to a bug in xorm (see https://gitea.com/xorm/xorm/pulls/1957) - it's
+// possible that a Blob may be double encoded or gain an unwanted prefix of 0xff 0xfe.
+func UnmarshalHandleDoubleEncode(bs []byte, v any) error {
+ err := json.Unmarshal(bs, v)
+ if err != nil {
+ ok := true
+ rs := []byte{}
+ temp := make([]byte, 2)
+ for _, rn := range string(bs) {
+ if rn > 0xffff {
+ ok = false
+ break
+ }
+ binary.LittleEndian.PutUint16(temp, uint16(rn))
+ rs = append(rs, temp...)
+ }
+ if ok {
+ if len(rs) > 1 && rs[0] == 0xff && rs[1] == 0xfe {
+ rs = rs[2:]
+ }
+ err = json.Unmarshal(rs, v)
+ }
+ }
+ if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe {
+ err = json.Unmarshal(bs[2:], v)
+ }
+ return err
+}
diff --git a/modules/keying/keying.go b/modules/keying/keying.go
new file mode 100644
index 0000000..7c595c7
--- /dev/null
+++ b/modules/keying/keying.go
@@ -0,0 +1,125 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Keying is a module that allows for subkeys to be determistically generated
+// from the same master key. It allows for domain seperation to take place by
+// using new keys for new subsystems/domains. These subkeys are provided with
+// an API to encrypt and decrypt data. The module panics if a bad interaction
+// happened, the panic should be seen as an non-recoverable error.
+//
+// HKDF (per RFC 5869) is used to derive new subkeys in a safe manner. It
+// provides a KDF security property, which is required for Forgejo, as the
+// secret key would be an ASCII string and isn't a random uniform bit string.
+// XChaCha-Poly1305 (per draft-irtf-cfrg-xchacha-01) is used as AEAD to encrypt
+// and decrypt messages. A new fresh random nonce is generated for every
+// encryption. The nonce gets prepended to the ciphertext.
+package keying
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/binary"
+
+ "golang.org/x/crypto/chacha20poly1305"
+ "golang.org/x/crypto/hkdf"
+)
+
+var (
+ // The hash used for HKDF.
+ hash = sha256.New
+ // The AEAD used for encryption/decryption.
+ aead = chacha20poly1305.NewX
+ aeadKeySize = chacha20poly1305.KeySize
+ aeadNonceSize = chacha20poly1305.NonceSizeX
+ // The pseudorandom key generated by HKDF-Extract.
+ prk []byte
+)
+
+// Set the main IKM for this module.
+func Init(ikm []byte) {
+ // Salt is intentionally left empty, it's not useful to Forgejo's use case.
+ prk = hkdf.Extract(hash, ikm, nil)
+}
+
+// Specifies the context for which a subkey should be derived for.
+// This must be a hardcoded string and must not be arbitrarily constructed.
+type Context string
+
+// Used for the `push_mirror` table.
+var ContextPushMirror Context = "pushmirror"
+
+// Derive *the* key for a given context, this is a determistic function. The
+// same key will be provided for the same context.
+func DeriveKey(context Context) *Key {
+ if len(prk) == 0 {
+ panic("keying: not initialized")
+ }
+
+ r := hkdf.Expand(hash, prk, []byte(context))
+
+ key := make([]byte, aeadKeySize)
+ // This should never return an error, but if it does, panic.
+ if _, err := r.Read(key); err != nil {
+ panic(err)
+ }
+
+ return &Key{key}
+}
+
+type Key struct {
+ key []byte
+}
+
+// Encrypts the specified plaintext with some additional data that is tied to
+// this plaintext. The additional data can be seen as the context in which the
+// data is being encrypted for, this is different than the context for which the
+// key was derrived this allows for more granuality without deriving new keys.
+// Avoid any user-generated data to be passed into the additional data. The most
+// common usage of this would be to encrypt a database field, in that case use
+// the ID and database column name as additional data. The additional data isn't
+// appended to the ciphertext and may be publicly known, it must be available
+// when decryping the ciphertext.
+func (k *Key) Encrypt(plaintext, additionalData []byte) []byte {
+ // Construct a new AEAD with the key.
+ e, err := aead(k.key)
+ if err != nil {
+ panic(err)
+ }
+
+ // Generate a random nonce.
+ nonce := make([]byte, aeadNonceSize)
+ if _, err := rand.Read(nonce); err != nil {
+ panic(err)
+ }
+
+ // Returns the ciphertext of this plaintext.
+ return e.Seal(nonce, nonce, plaintext, additionalData)
+}
+
+// Decrypts the ciphertext and authenticates it against the given additional
+// data that was given when it was encrypted. It returns an error if the
+// authentication failed.
+func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
+ if len(ciphertext) <= aeadNonceSize {
+ panic("keying: ciphertext is too short")
+ }
+
+ e, err := aead(k.key)
+ if err != nil {
+ panic(err)
+ }
+
+ nonce, ciphertext := ciphertext[:aeadNonceSize], ciphertext[aeadNonceSize:]
+
+ return e.Open(nil, nonce, ciphertext, additionalData)
+}
+
+// ColumnAndID generates a context that can be used as additional context for
+// encrypting and decrypting data. It requires the column name and the row ID
+// (this requires to be known beforehand). Be careful when using this, as the
+// table name isn't part of this context. This means it's not bound to a
+// particular table. The table should be part of the context that the key was
+// derived for, in which case it binds through that.
+func ColumnAndID(column string, id int64) []byte {
+ return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
+}
diff --git a/modules/keying/keying_test.go b/modules/keying/keying_test.go
new file mode 100644
index 0000000..8a6e8d5
--- /dev/null
+++ b/modules/keying/keying_test.go
@@ -0,0 +1,111 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package keying_test
+
+import (
+ "math"
+ "testing"
+
+ "code.gitea.io/gitea/modules/keying"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/chacha20poly1305"
+)
+
+func TestKeying(t *testing.T) {
+ t.Run("Not initalized", func(t *testing.T) {
+ assert.Panics(t, func() {
+ keying.DeriveKey(keying.Context("TESTING"))
+ })
+ })
+
+ t.Run("Initialization", func(t *testing.T) {
+ keying.Init([]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07})
+ })
+
+ t.Run("Context seperation", func(t *testing.T) {
+ key1 := keying.DeriveKey(keying.Context("TESTING"))
+ key2 := keying.DeriveKey(keying.Context("TESTING2"))
+
+ ciphertext := key1.Encrypt([]byte("This is for context TESTING"), nil)
+
+ plaintext, err := key2.Decrypt(ciphertext, nil)
+ require.Error(t, err)
+ assert.Empty(t, plaintext)
+
+ plaintext, err = key1.Decrypt(ciphertext, nil)
+ require.NoError(t, err)
+ assert.EqualValues(t, "This is for context TESTING", plaintext)
+ })
+
+ context := keying.Context("TESTING PURPOSES")
+ plainText := []byte("Forgejo is run by [Redacted]")
+ var cipherText []byte
+ t.Run("Encrypt", func(t *testing.T) {
+ key := keying.DeriveKey(context)
+
+ cipherText = key.Encrypt(plainText, []byte{0x05, 0x06})
+ cipherText2 := key.Encrypt(plainText, []byte{0x05, 0x06})
+
+ // Ensure ciphertexts don't have an determistic output.
+ assert.NotEqualValues(t, cipherText, cipherText2)
+ })
+
+ t.Run("Decrypt", func(t *testing.T) {
+ key := keying.DeriveKey(context)
+
+ t.Run("Succesful", func(t *testing.T) {
+ convertedPlainText, err := key.Decrypt(cipherText, []byte{0x05, 0x06})
+ require.NoError(t, err)
+ assert.EqualValues(t, plainText, convertedPlainText)
+ })
+
+ t.Run("Not enougn additional data", func(t *testing.T) {
+ plainText, err := key.Decrypt(cipherText, []byte{0x05})
+ require.Error(t, err)
+ assert.Empty(t, plainText)
+ })
+
+ t.Run("Too much additional data", func(t *testing.T) {
+ plainText, err := key.Decrypt(cipherText, []byte{0x05, 0x06, 0x07})
+ require.Error(t, err)
+ assert.Empty(t, plainText)
+ })
+
+ t.Run("Incorrect nonce", func(t *testing.T) {
+ // Flip the first byte of the nonce.
+ cipherText[0] = ^cipherText[0]
+
+ plainText, err := key.Decrypt(cipherText, []byte{0x05, 0x06})
+ require.Error(t, err)
+ assert.Empty(t, plainText)
+ })
+
+ t.Run("Incorrect ciphertext", func(t *testing.T) {
+ assert.Panics(t, func() {
+ key.Decrypt(nil, nil)
+ })
+
+ assert.Panics(t, func() {
+ cipherText := make([]byte, chacha20poly1305.NonceSizeX)
+ key.Decrypt(cipherText, nil)
+ })
+ })
+ })
+}
+
+func TestKeyingColumnAndID(t *testing.T) {
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", math.MinInt64))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", -1))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", 0))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table", 1))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", math.MaxInt64))
+
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", math.MinInt64))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", -1))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", 0))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
+ assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
+}
diff --git a/modules/label/label.go b/modules/label/label.go
new file mode 100644
index 0000000..d3ef0e1
--- /dev/null
+++ b/modules/label/label.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package label
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// colorPattern is a regexp which can validate label color
+var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
+
+// Label represents label information loaded from template
+type Label struct {
+ Name string `yaml:"name"`
+ Color string `yaml:"color"`
+ Description string `yaml:"description,omitempty"`
+ Exclusive bool `yaml:"exclusive,omitempty"`
+}
+
+// NormalizeColor normalizes a color string to a 6-character hex code
+func NormalizeColor(color string) (string, error) {
+ // normalize case
+ color = strings.TrimSpace(strings.ToLower(color))
+
+ // add leading hash
+ if len(color) == 6 || len(color) == 3 {
+ color = "#" + color
+ }
+
+ if !colorPattern.MatchString(color) {
+ return "", fmt.Errorf("bad color code: %s", color)
+ }
+
+ // convert 3-character shorthand into 6-character version
+ if len(color) == 4 {
+ r := color[1]
+ g := color[2]
+ b := color[3]
+ color = fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b)
+ }
+
+ return color, nil
+}
diff --git a/modules/label/parser.go b/modules/label/parser.go
new file mode 100644
index 0000000..511bac8
--- /dev/null
+++ b/modules/label/parser.go
@@ -0,0 +1,118 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package label
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/modules/options"
+
+ "gopkg.in/yaml.v3"
+)
+
+type labelFile struct {
+ Labels []*Label `yaml:"labels"`
+}
+
+// ErrTemplateLoad represents a "ErrTemplateLoad" kind of error.
+type ErrTemplateLoad struct {
+ TemplateFile string
+ OriginalError error
+}
+
+// IsErrTemplateLoad checks if an error is a ErrTemplateLoad.
+func IsErrTemplateLoad(err error) bool {
+ _, ok := err.(ErrTemplateLoad)
+ return ok
+}
+
+func (err ErrTemplateLoad) Error() string {
+ return fmt.Sprintf("failed to load label template file %q: %v", err.TemplateFile, err.OriginalError)
+}
+
+// LoadTemplateFile loads the label template file by given file name, returns a slice of Label structs.
+func LoadTemplateFile(fileName string) ([]*Label, error) {
+ data, err := options.Labels(fileName)
+ if err != nil {
+ return nil, ErrTemplateLoad{fileName, fmt.Errorf("LoadTemplateFile: %w", err)}
+ }
+
+ if strings.HasSuffix(fileName, ".yaml") || strings.HasSuffix(fileName, ".yml") {
+ return parseYamlFormat(fileName, data)
+ }
+ return parseLegacyFormat(fileName, data)
+}
+
+func parseYamlFormat(fileName string, data []byte) ([]*Label, error) {
+ lf := &labelFile{}
+
+ if err := yaml.Unmarshal(data, lf); err != nil {
+ return nil, err
+ }
+
+ // Validate label data and fix colors
+ for _, l := range lf.Labels {
+ l.Color = strings.TrimSpace(l.Color)
+ if len(l.Name) == 0 || len(l.Color) == 0 {
+ return nil, ErrTemplateLoad{fileName, errors.New("label name and color are required fields")}
+ }
+ color, err := NormalizeColor(l.Color)
+ if err != nil {
+ return nil, ErrTemplateLoad{fileName, fmt.Errorf("bad HTML color code '%s' in label: %s", l.Color, l.Name)}
+ }
+ l.Color = color
+ }
+
+ return lf.Labels, nil
+}
+
+func parseLegacyFormat(fileName string, data []byte) ([]*Label, error) {
+ lines := strings.Split(string(data), "\n")
+ list := make([]*Label, 0, len(lines))
+ for i := 0; i < len(lines); i++ {
+ line := strings.TrimSpace(lines[i])
+ if len(line) == 0 {
+ continue
+ }
+
+ parts, description, _ := strings.Cut(line, ";")
+
+ color, labelName, ok := strings.Cut(parts, " ")
+ if !ok {
+ return nil, ErrTemplateLoad{fileName, fmt.Errorf("line is malformed: %s", line)}
+ }
+
+ color, err := NormalizeColor(color)
+ if err != nil {
+ return nil, ErrTemplateLoad{fileName, fmt.Errorf("bad HTML color code '%s' in line: %s", color, line)}
+ }
+
+ list = append(list, &Label{
+ Name: strings.TrimSpace(labelName),
+ Color: color,
+ Description: strings.TrimSpace(description),
+ })
+ }
+
+ return list, nil
+}
+
+// LoadTemplateDescription loads the labels from a template file, returns a description string by joining each Label.Name with comma
+func LoadTemplateDescription(fileName string) (string, error) {
+ var buf strings.Builder
+ list, err := LoadTemplateFile(fileName)
+ if err != nil {
+ return "", err
+ }
+
+ for i := 0; i < len(list); i++ {
+ if i > 0 {
+ buf.WriteString(", ")
+ }
+ buf.WriteString(list[i].Name)
+ }
+ return buf.String(), nil
+}
diff --git a/modules/label/parser_test.go b/modules/label/parser_test.go
new file mode 100644
index 0000000..5c8042f
--- /dev/null
+++ b/modules/label/parser_test.go
@@ -0,0 +1,72 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package label
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestYamlParser(t *testing.T) {
+ data := []byte(`labels:
+ - name: priority/low
+ exclusive: true
+ color: "#0000ee"
+ description: "Low priority"
+ - name: priority/medium
+ exclusive: true
+ color: "0e0"
+ description: "Medium priority"
+ - name: priority/high
+ exclusive: true
+ color: "#ee0000"
+ description: "High priority"
+ - name: type/bug
+ color: "#f00"
+ description: "Bug"`)
+
+ labels, err := parseYamlFormat("test", data)
+ require.NoError(t, err)
+ require.Len(t, labels, 4)
+ assert.Equal(t, "priority/low", labels[0].Name)
+ assert.True(t, labels[0].Exclusive)
+ assert.Equal(t, "#0000ee", labels[0].Color)
+ assert.Equal(t, "Low priority", labels[0].Description)
+ assert.Equal(t, "priority/medium", labels[1].Name)
+ assert.True(t, labels[1].Exclusive)
+ assert.Equal(t, "#00ee00", labels[1].Color)
+ assert.Equal(t, "Medium priority", labels[1].Description)
+ assert.Equal(t, "priority/high", labels[2].Name)
+ assert.True(t, labels[2].Exclusive)
+ assert.Equal(t, "#ee0000", labels[2].Color)
+ assert.Equal(t, "High priority", labels[2].Description)
+ assert.Equal(t, "type/bug", labels[3].Name)
+ assert.False(t, labels[3].Exclusive)
+ assert.Equal(t, "#ff0000", labels[3].Color)
+ assert.Equal(t, "Bug", labels[3].Description)
+}
+
+func TestLegacyParser(t *testing.T) {
+ data := []byte(`#ee0701 bug ; Something is not working
+#cccccc duplicate ; This issue or pull request already exists
+#84b6eb enhancement`)
+
+ labels, err := parseLegacyFormat("test", data)
+ require.NoError(t, err)
+ require.Len(t, labels, 3)
+ assert.Equal(t, "bug", labels[0].Name)
+ assert.False(t, labels[0].Exclusive)
+ assert.Equal(t, "#ee0701", labels[0].Color)
+ assert.Equal(t, "Something is not working", labels[0].Description)
+ assert.Equal(t, "duplicate", labels[1].Name)
+ assert.False(t, labels[1].Exclusive)
+ assert.Equal(t, "#cccccc", labels[1].Color)
+ assert.Equal(t, "This issue or pull request already exists", labels[1].Description)
+ assert.Equal(t, "enhancement", labels[2].Name)
+ assert.False(t, labels[2].Exclusive)
+ assert.Equal(t, "#84b6eb", labels[2].Color)
+ assert.Empty(t, labels[2].Description)
+}
diff --git a/modules/lfs/LICENSE b/modules/lfs/LICENSE
new file mode 100644
index 0000000..0a94a80
--- /dev/null
+++ b/modules/lfs/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2016 The Gitea Authors
+Copyright (c) GitHub, Inc. and LFS Test Server contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/modules/lfs/client.go b/modules/lfs/client.go
new file mode 100644
index 0000000..f810e5c
--- /dev/null
+++ b/modules/lfs/client.go
@@ -0,0 +1,32 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/url"
+)
+
+// DownloadCallback gets called for every requested LFS object to process its content
+type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error
+
+// UploadCallback gets called for every requested LFS object to provide its content
+type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
+
+// Client is used to communicate with a LFS source
+type Client interface {
+ BatchSize() int
+ Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
+ Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
+}
+
+// NewClient creates a LFS client
+func NewClient(endpoint *url.URL, httpTransport *http.Transport) Client {
+ if endpoint.Scheme == "file" {
+ return newFilesystemClient(endpoint)
+ }
+ return newHTTPClient(endpoint, httpTransport)
+}
diff --git a/modules/lfs/client_test.go b/modules/lfs/client_test.go
new file mode 100644
index 0000000..a136930
--- /dev/null
+++ b/modules/lfs/client_test.go
@@ -0,0 +1,21 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewClient(t *testing.T) {
+ u, _ := url.Parse("file:///test")
+ c := NewClient(u, nil)
+ assert.IsType(t, &FilesystemClient{}, c)
+
+ u, _ = url.Parse("https://test.com/lfs")
+ c = NewClient(u, nil)
+ assert.IsType(t, &HTTPClient{}, c)
+}
diff --git a/modules/lfs/content_store.go b/modules/lfs/content_store.go
new file mode 100644
index 0000000..0d9c0c9
--- /dev/null
+++ b/modules/lfs/content_store.go
@@ -0,0 +1,163 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "hash"
+ "io"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/storage"
+)
+
+var (
+ // ErrHashMismatch occurs if the content has does not match OID
+ ErrHashMismatch = errors.New("content hash does not match OID")
+ // ErrSizeMismatch occurs if the content size does not match
+ ErrSizeMismatch = errors.New("content size does not match")
+)
+
+// ContentStore provides a simple file system based storage.
+type ContentStore struct {
+ storage.ObjectStorage
+}
+
+// NewContentStore creates the default ContentStore
+func NewContentStore() *ContentStore {
+ contentStore := &ContentStore{ObjectStorage: storage.LFS}
+ return contentStore
+}
+
+// Get takes a Meta object and retrieves the content from the store, returning
+// it as an io.ReadSeekCloser.
+func (s *ContentStore) Get(pointer Pointer) (storage.Object, error) {
+ f, err := s.Open(pointer.RelativePath())
+ if err != nil {
+ log.Error("Whilst trying to read LFS OID[%s]: Unable to open Error: %v", pointer.Oid, err)
+ return nil, err
+ }
+ return f, err
+}
+
+// Put takes a Meta object and an io.Reader and writes the content to the store.
+func (s *ContentStore) Put(pointer Pointer, r io.Reader) error {
+ p := pointer.RelativePath()
+
+ // Wrap the provided reader with an inline hashing and size checker
+ wrappedRd := newHashingReader(pointer.Size, pointer.Oid, r)
+
+ // now pass the wrapped reader to Save - if there is a size mismatch or hash mismatch then
+ // the errors returned by the newHashingReader should percolate up to here
+ written, err := s.Save(p, wrappedRd, pointer.Size)
+ if err != nil {
+ log.Error("Whilst putting LFS OID[%s]: Failed to copy to tmpPath: %s Error: %v", pointer.Oid, p, err)
+ return err
+ }
+
+ // check again whether there is any error during the Save operation
+ // because some errors might be ignored by the Reader's caller
+ if wrappedRd.lastError != nil && !errors.Is(wrappedRd.lastError, io.EOF) {
+ err = wrappedRd.lastError
+ } else if written != pointer.Size {
+ err = ErrSizeMismatch
+ }
+
+ // if the upload failed, try to delete the file
+ if err != nil {
+ if errDel := s.Delete(p); errDel != nil {
+ log.Error("Cleaning the LFS OID[%s] failed: %v", pointer.Oid, errDel)
+ }
+ }
+
+ return err
+}
+
+// Exists returns true if the object exists in the content store.
+func (s *ContentStore) Exists(pointer Pointer) (bool, error) {
+ _, err := s.ObjectStorage.Stat(pointer.RelativePath())
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+// Verify returns true if the object exists in the content store and size is correct.
+func (s *ContentStore) Verify(pointer Pointer) (bool, error) {
+ p := pointer.RelativePath()
+ fi, err := s.ObjectStorage.Stat(p)
+ if os.IsNotExist(err) || (err == nil && fi.Size() != pointer.Size) {
+ return false, nil
+ } else if err != nil {
+ log.Error("Unable stat file: %s for LFS OID[%s] Error: %v", p, pointer.Oid, err)
+ return false, err
+ }
+
+ return true, nil
+}
+
+// ReadMetaObject will read a git_model.LFSMetaObject and return a reader
+func ReadMetaObject(pointer Pointer) (io.ReadSeekCloser, error) {
+ contentStore := NewContentStore()
+ return contentStore.Get(pointer)
+}
+
+type hashingReader struct {
+ internal io.Reader
+ currentSize int64
+ expectedSize int64
+ hash hash.Hash
+ expectedHash string
+ lastError error
+}
+
+// recordError records the last error during the Save operation
+// Some callers of the Reader doesn't respect the returned "err"
+// For example, MinIO's Put will ignore errors if the written size could equal to expected size
+// So we must remember the error by ourselves,
+// and later check again whether ErrSizeMismatch or ErrHashMismatch occurs during the Save operation
+func (r *hashingReader) recordError(err error) error {
+ r.lastError = err
+ return err
+}
+
+func (r *hashingReader) Read(b []byte) (int, error) {
+ n, err := r.internal.Read(b)
+
+ if n > 0 {
+ r.currentSize += int64(n)
+ wn, werr := r.hash.Write(b[:n])
+ if wn != n || werr != nil {
+ return n, r.recordError(werr)
+ }
+ }
+
+ if errors.Is(err, io.EOF) || r.currentSize >= r.expectedSize {
+ if r.currentSize != r.expectedSize {
+ return n, r.recordError(ErrSizeMismatch)
+ }
+
+ shaStr := hex.EncodeToString(r.hash.Sum(nil))
+ if shaStr != r.expectedHash {
+ return n, r.recordError(ErrHashMismatch)
+ }
+ }
+
+ return n, r.recordError(err)
+}
+
+func newHashingReader(expectedSize int64, expectedHash string, reader io.Reader) *hashingReader {
+ return &hashingReader{
+ internal: reader,
+ expectedSize: expectedSize,
+ expectedHash: expectedHash,
+ hash: sha256.New(),
+ }
+}
diff --git a/modules/lfs/endpoint.go b/modules/lfs/endpoint.go
new file mode 100644
index 0000000..97bd7d4
--- /dev/null
+++ b/modules/lfs/endpoint.go
@@ -0,0 +1,107 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// DetermineEndpoint determines an endpoint from the clone url or uses the specified LFS url.
+func DetermineEndpoint(cloneurl, lfsurl string) *url.URL {
+ if len(lfsurl) > 0 {
+ return endpointFromURL(lfsurl)
+ }
+ return endpointFromCloneURL(cloneurl)
+}
+
+func endpointFromCloneURL(rawurl string) *url.URL {
+ ep := endpointFromURL(rawurl)
+ if ep == nil {
+ return ep
+ }
+
+ ep.Path = strings.TrimSuffix(ep.Path, "/")
+
+ if ep.Scheme == "file" {
+ return ep
+ }
+
+ if path.Ext(ep.Path) == ".git" {
+ ep.Path += "/info/lfs"
+ } else {
+ ep.Path += ".git/info/lfs"
+ }
+
+ return ep
+}
+
+func endpointFromURL(rawurl string) *url.URL {
+ if strings.HasPrefix(rawurl, "/") {
+ return endpointFromLocalPath(rawurl)
+ }
+
+ u, err := url.Parse(rawurl)
+ if err != nil {
+ log.Error("lfs.endpointFromUrl: %v", err)
+ return nil
+ }
+
+ switch u.Scheme {
+ case "http", "https":
+ return u
+ case "git":
+ u.Scheme = "https"
+ return u
+ case "ssh":
+ u.Scheme = "https"
+ u.User = nil
+ return u
+ case "file":
+ return u
+ default:
+ if _, err := os.Stat(rawurl); err == nil {
+ return endpointFromLocalPath(rawurl)
+ }
+
+ log.Error("lfs.endpointFromUrl: unknown url")
+ return nil
+ }
+}
+
+func endpointFromLocalPath(path string) *url.URL {
+ var slash string
+ if abs, err := filepath.Abs(path); err == nil {
+ if !strings.HasPrefix(abs, "/") {
+ slash = "/"
+ }
+ path = abs
+ }
+
+ var gitpath string
+ if filepath.Base(path) == ".git" {
+ gitpath = path
+ path = filepath.Dir(path)
+ } else {
+ gitpath = filepath.Join(path, ".git")
+ }
+
+ if _, err := os.Stat(gitpath); err == nil {
+ path = gitpath
+ } else if _, err := os.Stat(path); err != nil {
+ return nil
+ }
+
+ path = "file://" + slash + util.PathEscapeSegments(filepath.ToSlash(path))
+
+ u, _ := url.Parse(path)
+
+ return u
+}
diff --git a/modules/lfs/endpoint_test.go b/modules/lfs/endpoint_test.go
new file mode 100644
index 0000000..118abe2
--- /dev/null
+++ b/modules/lfs/endpoint_test.go
@@ -0,0 +1,74 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func str2url(raw string) *url.URL {
+ u, _ := url.Parse(raw)
+ return u
+}
+
+func TestDetermineEndpoint(t *testing.T) {
+ // Test cases
+ cases := []struct {
+ cloneurl string
+ lfsurl string
+ expected *url.URL
+ }{
+ // case 0
+ {
+ cloneurl: "",
+ lfsurl: "",
+ expected: nil,
+ },
+ // case 1
+ {
+ cloneurl: "https://git.com/repo",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 2
+ {
+ cloneurl: "https://git.com/repo.git",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 3
+ {
+ cloneurl: "",
+ lfsurl: "https://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ // case 4
+ {
+ cloneurl: "https://git.com/repo.git",
+ lfsurl: "https://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ // case 5
+ {
+ cloneurl: "git://git.com/repo.git",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 6
+ {
+ cloneurl: "",
+ lfsurl: "git://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ }
+
+ for n, c := range cases {
+ ep := DetermineEndpoint(c.cloneurl, c.lfsurl)
+
+ assert.Equal(t, c.expected, ep, "case %d: error should match", n)
+ }
+}
diff --git a/modules/lfs/filesystem_client.go b/modules/lfs/filesystem_client.go
new file mode 100644
index 0000000..71bef5c
--- /dev/null
+++ b/modules/lfs/filesystem_client.go
@@ -0,0 +1,88 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "context"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// FilesystemClient is used to read LFS data from a filesystem path
+type FilesystemClient struct {
+ lfsDir string
+}
+
+// BatchSize returns the preferred size of batchs to process
+func (c *FilesystemClient) BatchSize() int {
+ return 1
+}
+
+func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
+ path, _ := util.FileURLToPath(endpoint)
+ lfsDir := filepath.Join(path, "lfs", "objects")
+ return &FilesystemClient{lfsDir}
+}
+
+func (c *FilesystemClient) objectPath(oid string) string {
+ return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid)
+}
+
+// Download reads the specific LFS object from the target path
+func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
+ for _, object := range objects {
+ p := Pointer{object.Oid, object.Size}
+
+ objectPath := c.objectPath(p.Oid)
+
+ f, err := os.Open(objectPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := callback(p, f, nil); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Upload writes the specific LFS object to the target path
+func (c *FilesystemClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
+ for _, object := range objects {
+ p := Pointer{object.Oid, object.Size}
+
+ objectPath := c.objectPath(p.Oid)
+
+ if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
+ return err
+ }
+
+ content, err := callback(p, nil)
+ if err != nil {
+ return err
+ }
+
+ err = func() error {
+ defer content.Close()
+
+ f, err := os.Create(objectPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = io.Copy(f, content)
+
+ return err
+ }()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go
new file mode 100644
index 0000000..4859fe6
--- /dev/null
+++ b/modules/lfs/http_client.go
@@ -0,0 +1,259 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/proxy"
+)
+
+const httpBatchSize = 20
+
+// HTTPClient is used to communicate with the LFS server
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
+type HTTPClient struct {
+ client *http.Client
+ endpoint string
+ transfers map[string]TransferAdapter
+}
+
+// BatchSize returns the preferred size of batchs to process
+func (c *HTTPClient) BatchSize() int {
+ return httpBatchSize
+}
+
+func newHTTPClient(endpoint *url.URL, httpTransport *http.Transport) *HTTPClient {
+ if httpTransport == nil {
+ httpTransport = &http.Transport{
+ Proxy: proxy.Proxy(),
+ }
+ }
+
+ hc := &http.Client{
+ Transport: httpTransport,
+ }
+
+ basic := &BasicTransferAdapter{hc}
+ client := &HTTPClient{
+ client: hc,
+ endpoint: strings.TrimSuffix(endpoint.String(), "/"),
+ transfers: map[string]TransferAdapter{
+ basic.Name(): basic,
+ },
+ }
+
+ return client
+}
+
+func (c *HTTPClient) transferNames() []string {
+ keys := make([]string, len(c.transfers))
+ i := 0
+ for k := range c.transfers {
+ keys[i] = k
+ i++
+ }
+ return keys
+}
+
+func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
+ log.Trace("BATCH operation with objects: %v", objects)
+
+ url := fmt.Sprintf("%s/objects/batch", c.endpoint)
+
+ request := &BatchRequest{operation, c.transferNames(), nil, objects}
+ payload := new(bytes.Buffer)
+ err := json.NewEncoder(payload).Encode(request)
+ if err != nil {
+ log.Error("Error encoding json: %v", err)
+ return nil, err
+ }
+
+ req, err := createRequest(ctx, http.MethodPost, url, map[string]string{"Content-Type": MediaType}, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ res, err := performRequest(ctx, c.client, req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ var response BatchResponse
+ err = json.NewDecoder(res.Body).Decode(&response)
+ if err != nil {
+ log.Error("Error decoding json: %v", err)
+ return nil, err
+ }
+
+ if len(response.Transfer) == 0 {
+ response.Transfer = "basic"
+ }
+
+ return &response, nil
+}
+
+// Download reads the specific LFS object from the LFS server
+func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
+ return c.performOperation(ctx, objects, callback, nil)
+}
+
+// Upload sends the specific LFS object to the LFS server
+func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
+ return c.performOperation(ctx, objects, nil, callback)
+}
+
+func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
+ if len(objects) == 0 {
+ return nil
+ }
+
+ operation := "download"
+ if uc != nil {
+ operation = "upload"
+ }
+
+ result, err := c.batch(ctx, operation, objects)
+ if err != nil {
+ return err
+ }
+
+ transferAdapter, ok := c.transfers[result.Transfer]
+ if !ok {
+ return fmt.Errorf("TransferAdapter not found: %s", result.Transfer)
+ }
+
+ for _, object := range result.Objects {
+ if object.Error != nil {
+ log.Trace("Error on object %v: %v", object.Pointer, object.Error)
+ if uc != nil {
+ if _, err := uc(object.Pointer, object.Error); err != nil {
+ return err
+ }
+ } else {
+ if err := dc(object.Pointer, nil, object.Error); err != nil {
+ return err
+ }
+ }
+ continue
+ }
+
+ if uc != nil {
+ if len(object.Actions) == 0 {
+ log.Trace("%v already present on server", object.Pointer)
+ continue
+ }
+
+ link, ok := object.Actions["upload"]
+ if !ok {
+ log.Debug("%+v", object)
+ return errors.New("missing action 'upload'")
+ }
+
+ content, err := uc(object.Pointer, nil)
+ if err != nil {
+ return err
+ }
+
+ err = transferAdapter.Upload(ctx, link, object.Pointer, content)
+ if err != nil {
+ return err
+ }
+
+ link, ok = object.Actions["verify"]
+ if ok {
+ if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil {
+ return err
+ }
+ }
+ } else {
+ link, ok := object.Actions["download"]
+ if !ok {
+ // no actions block in response, try legacy response schema
+ link, ok = object.Links["download"]
+ }
+ if !ok {
+ log.Debug("%+v", object)
+ return errors.New("missing action 'download'")
+ }
+
+ content, err := transferAdapter.Download(ctx, link)
+ if err != nil {
+ return err
+ }
+
+ if err := dc(object.Pointer, content, nil); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// createRequest creates a new request, and sets the headers.
+func createRequest(ctx context.Context, method, url string, headers map[string]string, body io.Reader) (*http.Request, error) {
+ log.Trace("createRequest: %s", url)
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ log.Error("Error creating request: %v", err)
+ return nil, err
+ }
+
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+ req.Header.Set("Accept", AcceptHeader)
+
+ return req, nil
+}
+
+// performRequest sends a request, optionally performs a callback on the request and returns the response.
+// If the status code is 200, the response is returned, and it will contain a non-nil Body.
+// Otherwise, it will return an error, and the Body will be nil or closed.
+func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
+ log.Trace("performRequest: %s", req.URL)
+ res, err := client.Do(req)
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return res, ctx.Err()
+ default:
+ }
+ log.Error("Error while processing request: %v", err)
+ return res, err
+ }
+
+ if res.StatusCode != http.StatusOK {
+ defer res.Body.Close()
+ return res, handleErrorResponse(res)
+ }
+
+ return res, nil
+}
+
+func handleErrorResponse(resp *http.Response) error {
+ var er ErrorResponse
+ err := json.NewDecoder(resp.Body).Decode(&er)
+ if err != nil {
+ if err == io.EOF {
+ return io.ErrUnexpectedEOF
+ }
+ log.Error("Error decoding json: %v", err)
+ return err
+ }
+
+ log.Trace("ErrorResponse(%v): %v", resp.Status, er)
+ return errors.New(er.Message)
+}
diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go
new file mode 100644
index 0000000..534a445
--- /dev/null
+++ b/modules/lfs/http_client_test.go
@@ -0,0 +1,377 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type RoundTripFunc func(req *http.Request) *http.Response
+
+func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req), nil
+}
+
+type DummyTransferAdapter struct{}
+
+func (a *DummyTransferAdapter) Name() string {
+ return "dummy"
+}
+
+func (a *DummyTransferAdapter) Download(ctx context.Context, l *Link) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewBufferString("dummy")), nil
+}
+
+func (a *DummyTransferAdapter) Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error {
+ return nil
+}
+
+func (a *DummyTransferAdapter) Verify(ctx context.Context, l *Link, p Pointer) error {
+ return nil
+}
+
+func lfsTestRoundtripHandler(req *http.Request) *http.Response {
+ var batchResponse *BatchResponse
+ url := req.URL.String()
+
+ if strings.Contains(url, "status-not-ok") {
+ return &http.Response{StatusCode: http.StatusBadRequest}
+ } else if strings.Contains(url, "invalid-json-response") {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString("invalid json"))}
+ } else if strings.Contains(url, "valid-batch-request-download") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "download": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "legacy-batch-request-download") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Links: map[string]*Link{
+ "download": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "valid-batch-request-upload") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "upload": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "response-no-objects") {
+ batchResponse = &BatchResponse{Transfer: "dummy"}
+ } else if strings.Contains(url, "unknown-transfer-adapter") {
+ batchResponse = &BatchResponse{Transfer: "unknown_adapter"}
+ } else if strings.Contains(url, "error-in-response-objects") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Error: &ObjectError{
+ Code: http.StatusNotFound,
+ Message: "Object not found",
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "empty-actions-map") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{},
+ },
+ },
+ }
+ } else if strings.Contains(url, "download-actions-map") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "download": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "upload-actions-map") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "upload": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "verify-actions-map") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "verify": {},
+ },
+ },
+ },
+ }
+ } else if strings.Contains(url, "unknown-actions-map") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Actions: map[string]*Link{
+ "unknown": {},
+ },
+ },
+ },
+ }
+ } else {
+ return nil
+ }
+
+ payload := new(bytes.Buffer)
+ json.NewEncoder(payload).Encode(batchResponse)
+
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(payload)}
+}
+
+func TestHTTPClientDownload(t *testing.T) {
+ p := Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6}
+
+ hc := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, MediaType, req.Header.Get("Content-type"))
+ assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
+
+ var batchRequest BatchRequest
+ err := json.NewDecoder(req.Body).Decode(&batchRequest)
+ require.NoError(t, err)
+
+ assert.Equal(t, "download", batchRequest.Operation)
+ assert.Len(t, batchRequest.Objects, 1)
+ assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
+ assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
+
+ return lfsTestRoundtripHandler(req)
+ })}
+ dummy := &DummyTransferAdapter{}
+
+ cases := []struct {
+ endpoint string
+ expectederror string
+ }{
+ // case 0
+ {
+ endpoint: "https://status-not-ok.io",
+ expectederror: io.ErrUnexpectedEOF.Error(),
+ },
+ // case 1
+ {
+ endpoint: "https://invalid-json-response.io",
+ expectederror: "invalid json",
+ },
+ // case 2
+ {
+ endpoint: "https://valid-batch-request-download.io",
+ expectederror: "",
+ },
+ // case 3
+ {
+ endpoint: "https://response-no-objects.io",
+ expectederror: "",
+ },
+ // case 4
+ {
+ endpoint: "https://unknown-transfer-adapter.io",
+ expectederror: "TransferAdapter not found: ",
+ },
+ // case 5
+ {
+ endpoint: "https://error-in-response-objects.io",
+ expectederror: "Object not found",
+ },
+ // case 6
+ {
+ endpoint: "https://empty-actions-map.io",
+ expectederror: "missing action 'download'",
+ },
+ // case 7
+ {
+ endpoint: "https://download-actions-map.io",
+ expectederror: "",
+ },
+ // case 8
+ {
+ endpoint: "https://upload-actions-map.io",
+ expectederror: "missing action 'download'",
+ },
+ // case 9
+ {
+ endpoint: "https://verify-actions-map.io",
+ expectederror: "missing action 'download'",
+ },
+ // case 10
+ {
+ endpoint: "https://unknown-actions-map.io",
+ expectederror: "missing action 'download'",
+ },
+ // case 11
+ {
+ endpoint: "https://legacy-batch-request-download.io",
+ expectederror: "",
+ },
+ }
+
+ for n, c := range cases {
+ client := &HTTPClient{
+ client: hc,
+ endpoint: c.endpoint,
+ transfers: map[string]TransferAdapter{
+ "dummy": dummy,
+ },
+ }
+
+ err := client.Download(context.Background(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
+ if objectError != nil {
+ return objectError
+ }
+ b, err := io.ReadAll(content)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("dummy"), b)
+ return nil
+ })
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ require.NoError(t, err, "case %d", n)
+ }
+ }
+}
+
+func TestHTTPClientUpload(t *testing.T) {
+ p := Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6}
+
+ hc := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, MediaType, req.Header.Get("Content-type"))
+ assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
+
+ var batchRequest BatchRequest
+ err := json.NewDecoder(req.Body).Decode(&batchRequest)
+ require.NoError(t, err)
+
+ assert.Equal(t, "upload", batchRequest.Operation)
+ assert.Len(t, batchRequest.Objects, 1)
+ assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
+ assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
+
+ return lfsTestRoundtripHandler(req)
+ })}
+ dummy := &DummyTransferAdapter{}
+
+ cases := []struct {
+ endpoint string
+ expectederror string
+ }{
+ // case 0
+ {
+ endpoint: "https://status-not-ok.io",
+ expectederror: io.ErrUnexpectedEOF.Error(),
+ },
+ // case 1
+ {
+ endpoint: "https://invalid-json-response.io",
+ expectederror: "invalid json",
+ },
+ // case 2
+ {
+ endpoint: "https://valid-batch-request-upload.io",
+ expectederror: "",
+ },
+ // case 3
+ {
+ endpoint: "https://response-no-objects.io",
+ expectederror: "",
+ },
+ // case 4
+ {
+ endpoint: "https://unknown-transfer-adapter.io",
+ expectederror: "TransferAdapter not found: ",
+ },
+ // case 5
+ {
+ endpoint: "https://error-in-response-objects.io",
+ expectederror: "Object not found",
+ },
+ // case 6
+ {
+ endpoint: "https://empty-actions-map.io",
+ expectederror: "",
+ },
+ // case 7
+ {
+ endpoint: "https://download-actions-map.io",
+ expectederror: "missing action 'upload'",
+ },
+ // case 8
+ {
+ endpoint: "https://upload-actions-map.io",
+ expectederror: "",
+ },
+ // case 9
+ {
+ endpoint: "https://verify-actions-map.io",
+ expectederror: "missing action 'upload'",
+ },
+ // case 10
+ {
+ endpoint: "https://unknown-actions-map.io",
+ expectederror: "missing action 'upload'",
+ },
+ }
+
+ for n, c := range cases {
+ client := &HTTPClient{
+ client: hc,
+ endpoint: c.endpoint,
+ transfers: map[string]TransferAdapter{
+ "dummy": dummy,
+ },
+ }
+
+ err := client.Upload(context.Background(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) {
+ return io.NopCloser(new(bytes.Buffer)), objectError
+ })
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ require.NoError(t, err, "case %d", n)
+ }
+ }
+}
diff --git a/modules/lfs/pointer.go b/modules/lfs/pointer.go
new file mode 100644
index 0000000..ebde20f
--- /dev/null
+++ b/modules/lfs/pointer.go
@@ -0,0 +1,129 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ blobSizeCutoff = 1024
+
+ // MetaFileIdentifier is the string appearing at the first line of LFS pointer files.
+ // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
+ MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1"
+
+ // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.
+ MetaFileOidPrefix = "oid sha256:"
+)
+
+var (
+ // ErrMissingPrefix occurs if the content lacks the LFS prefix
+ ErrMissingPrefix = errors.New("content lacks the LFS prefix")
+
+ // ErrInvalidStructure occurs if the content has an invalid structure
+ ErrInvalidStructure = errors.New("content has an invalid structure")
+
+ // ErrInvalidOIDFormat occurs if the oid has an invalid format
+ ErrInvalidOIDFormat = errors.New("OID has an invalid format")
+)
+
+// ReadPointer tries to read LFS pointer data from the reader
+func ReadPointer(reader io.Reader) (Pointer, error) {
+ buf := make([]byte, blobSizeCutoff)
+ n, err := io.ReadFull(reader, buf)
+ if err != nil && err != io.ErrUnexpectedEOF {
+ return Pointer{}, err
+ }
+ buf = buf[:n]
+
+ return ReadPointerFromBuffer(buf)
+}
+
+var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
+
+// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
+func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
+ var p Pointer
+
+ headString := string(buf)
+ if !strings.HasPrefix(headString, MetaFileIdentifier) {
+ return p, ErrMissingPrefix
+ }
+
+ splitLines := strings.Split(headString, "\n")
+ if len(splitLines) < 3 {
+ return p, ErrInvalidStructure
+ }
+
+ oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
+ if len(oid) != 64 || !oidPattern.MatchString(oid) {
+ return p, ErrInvalidOIDFormat
+ }
+ size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
+ if err != nil {
+ return p, err
+ }
+
+ p.Oid = oid
+ p.Size = size
+
+ return p, nil
+}
+
+// IsValid checks if the pointer has a valid structure.
+// It doesn't check if the pointed-to-content exists.
+func (p Pointer) IsValid() bool {
+ if len(p.Oid) != 64 {
+ return false
+ }
+ if !oidPattern.MatchString(p.Oid) {
+ return false
+ }
+ if p.Size < 0 {
+ return false
+ }
+ return true
+}
+
+// StringContent returns the string representation of the pointer
+// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer
+func (p Pointer) StringContent() string {
+ return fmt.Sprintf("%s\n%s%s\nsize %d\n", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size)
+}
+
+// RelativePath returns the relative storage path of the pointer
+func (p Pointer) RelativePath() string {
+ if len(p.Oid) < 5 {
+ return p.Oid
+ }
+
+ return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
+}
+
+func (p Pointer) LogString() string {
+ if p.Oid == "" && p.Size == 0 {
+ return "<LFSPointer empty>"
+ }
+ return fmt.Sprintf("<LFSPointer %s:%d>", p.Oid, p.Size)
+}
+
+// GeneratePointer generates a pointer for arbitrary content
+func GeneratePointer(content io.Reader) (Pointer, error) {
+ h := sha256.New()
+ c, err := io.Copy(h, content)
+ if err != nil {
+ return Pointer{}, err
+ }
+ sum := h.Sum(nil)
+ return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil
+}
diff --git a/modules/lfs/pointer_scanner.go b/modules/lfs/pointer_scanner.go
new file mode 100644
index 0000000..8bbf7a8
--- /dev/null
+++ b/modules/lfs/pointer_scanner.go
@@ -0,0 +1,109 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "strconv"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/git/pipeline"
+)
+
+// SearchPointerBlobs scans the whole repository for LFS pointer files
+func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {
+ basePath := repo.Path
+
+ catFileCheckReader, catFileCheckWriter := io.Pipe()
+ shasToBatchReader, shasToBatchWriter := io.Pipe()
+ catFileBatchReader, catFileBatchWriter := io.Pipe()
+
+ wg := sync.WaitGroup{}
+ wg.Add(4)
+
+ // Create the go-routines in reverse order.
+
+ // 4. Take the output of cat-file --batch and check if each file in turn
+ // to see if they're pointers to files in the LFS store
+ go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan)
+
+ // 3. Take the shas of the blobs and batch read them
+ go pipeline.CatFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath)
+
+ // 2. From the provided objects restrict to blobs <=1k
+ go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)
+
+ // 1. Run batch-check on all objects in the repository
+ if git.CheckGitVersionAtLeast("2.6.0") != nil {
+ revListReader, revListWriter := io.Pipe()
+ shasToCheckReader, shasToCheckWriter := io.Pipe()
+ wg.Add(2)
+ go pipeline.CatFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath)
+ go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg)
+ go pipeline.RevListAllObjects(ctx, revListWriter, &wg, basePath, errChan)
+ } else {
+ go pipeline.CatFileBatchCheckAllObjects(ctx, catFileCheckWriter, &wg, basePath, errChan)
+ }
+ wg.Wait()
+
+ close(pointerChan)
+ close(errChan)
+}
+
+func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) {
+ defer wg.Done()
+ defer catFileBatchReader.Close()
+
+ bufferedReader := bufio.NewReader(catFileBatchReader)
+ buf := make([]byte, 1025)
+
+loop:
+ for {
+ select {
+ case <-ctx.Done():
+ break loop
+ default:
+ }
+
+ // File descriptor line: sha
+ sha, err := bufferedReader.ReadString(' ')
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ sha = strings.TrimSpace(sha)
+ // Throw away the blob
+ if _, err := bufferedReader.ReadString(' '); err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ sizeStr, err := bufferedReader.ReadString('\n')
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ pointerBuf := buf[:size+1]
+ if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ pointerBuf = pointerBuf[:size]
+ // Now we need to check if the pointerBuf is an LFS pointer
+ pointer, _ := ReadPointerFromBuffer(pointerBuf)
+ if !pointer.IsValid() {
+ continue
+ }
+
+ pointerChan <- PointerBlob{Hash: sha, Pointer: pointer}
+ }
+}
diff --git a/modules/lfs/pointer_test.go b/modules/lfs/pointer_test.go
new file mode 100644
index 0000000..9299a8a
--- /dev/null
+++ b/modules/lfs/pointer_test.go
@@ -0,0 +1,103 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestStringContent(t *testing.T) {
+ p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", Size: 1234}
+ expected := "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"
+ assert.Equal(t, expected, p.StringContent())
+}
+
+func TestRelativePath(t *testing.T) {
+ p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393"}
+ expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
+ assert.Equal(t, expected, p.RelativePath())
+
+ p2 := Pointer{Oid: "4d7a"}
+ assert.Equal(t, "4d7a", p2.RelativePath())
+}
+
+func TestIsValid(t *testing.T) {
+ p := Pointer{}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "123"}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "z4cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
+ assert.True(t, p.IsValid())
+
+ p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc", Size: -1}
+ assert.False(t, p.IsValid())
+}
+
+func TestGeneratePointer(t *testing.T) {
+ p, err := GeneratePointer(strings.NewReader("Gitea"))
+ require.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc", p.Oid)
+ assert.Equal(t, int64(5), p.Size)
+}
+
+func TestReadPointerFromBuffer(t *testing.T) {
+ p, err := ReadPointerFromBuffer([]byte{})
+ require.ErrorIs(t, err, ErrMissingPrefix)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("test"))
+ require.ErrorIs(t, err, ErrMissingPrefix)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\n"))
+ require.ErrorIs(t, err, ErrInvalidStructure)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a\nsize 1234\n"))
+ require.ErrorIs(t, err, ErrInvalidOIDFormat)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a2146z4ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ require.ErrorIs(t, err, ErrInvalidOIDFormat)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\ntest 1234\n"))
+ require.Error(t, err)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize test\n"))
+ require.Error(t, err)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ require.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
+ assert.Equal(t, int64(1234), p.Size)
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\ntest"))
+ require.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
+ assert.Equal(t, int64(1234), p.Size)
+}
+
+func TestReadPointer(t *testing.T) {
+ p, err := ReadPointer(strings.NewReader("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ require.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
+ assert.Equal(t, int64(1234), p.Size)
+}
diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go
new file mode 100644
index 0000000..a4326b5
--- /dev/null
+++ b/modules/lfs/shared.go
@@ -0,0 +1,115 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ // MediaType contains the media type for LFS server requests
+ MediaType = "application/vnd.git-lfs+json"
+ // Some LFS servers offer content with other types, so fallback to '*/*' if application/vnd.git-lfs+json cannot be served
+ AcceptHeader = "application/vnd.git-lfs+json;q=0.9, */*;q=0.8"
+)
+
+// BatchRequest contains multiple requests processed in one batch operation.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests
+type BatchRequest struct {
+ Operation string `json:"operation"`
+ Transfers []string `json:"transfers,omitempty"`
+ Ref *Reference `json:"ref,omitempty"`
+ Objects []Pointer `json:"objects"`
+}
+
+// Reference contains a git reference.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property
+type Reference struct {
+ Name string `json:"name"`
+}
+
+// Pointer contains LFS pointer data
+type Pointer struct {
+ Oid string `json:"oid" xorm:"UNIQUE(s) INDEX NOT NULL"`
+ Size int64 `json:"size" xorm:"NOT NULL"`
+}
+
+// BatchResponse contains multiple object metadata Representation structures
+// for use with the batch API.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
+type BatchResponse struct {
+ Transfer string `json:"transfer,omitempty"`
+ Objects []*ObjectResponse `json:"objects"`
+}
+
+// ObjectResponse is object metadata as seen by clients of the LFS server.
+type ObjectResponse struct {
+ Pointer
+ Actions map[string]*Link `json:"actions,omitempty"`
+ Links map[string]*Link `json:"_links,omitempty"`
+ Error *ObjectError `json:"error,omitempty"`
+}
+
+// Link provides a structure with information about how to access a object.
+type Link struct {
+ Href string `json:"href"`
+ Header map[string]string `json:"header,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+// ObjectError defines the JSON structure returned to the client in case of an error.
+type ObjectError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+var (
+ // See https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
+ // LFS object error codes should match HTTP status codes where possible:
+ // 404 - The object does not exist on the server.
+ // 409 - The specified hash algorithm disagrees with the server's acceptable options.
+ // 410 - The object was removed by the owner.
+ // 422 - Validation error.
+
+ ErrObjectNotExist = util.ErrNotExist // the object does not exist on the server
+ ErrObjectHashMismatch = errors.New("the specified hash algorithm disagrees with the server's acceptable options")
+ ErrObjectRemoved = errors.New("the object was removed by the owner")
+ ErrObjectValidation = errors.New("validation error")
+)
+
+func (e *ObjectError) Error() string {
+ return fmt.Sprintf("[%d] %s", e.Code, e.Message)
+}
+
+func (e *ObjectError) Unwrap() error {
+ switch e.Code {
+ case 404:
+ return ErrObjectNotExist
+ case 409:
+ return ErrObjectHashMismatch
+ case 410:
+ return ErrObjectRemoved
+ case 422:
+ return ErrObjectValidation
+ default:
+ return errors.New(e.Message)
+ }
+}
+
+// PointerBlob associates a Git blob with a Pointer.
+type PointerBlob struct {
+ Hash string
+ Pointer
+}
+
+// ErrorResponse describes the error to the client.
+type ErrorResponse struct {
+ Message string
+ DocumentationURL string `json:"documentation_url,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
+}
diff --git a/modules/lfs/transferadapter.go b/modules/lfs/transferadapter.go
new file mode 100644
index 0000000..fbc3a3a
--- /dev/null
+++ b/modules/lfs/transferadapter.go
@@ -0,0 +1,89 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// TransferAdapter represents an adapter for downloading/uploading LFS objects.
+type TransferAdapter interface {
+ Name() string
+ Download(ctx context.Context, l *Link) (io.ReadCloser, error)
+ Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error
+ Verify(ctx context.Context, l *Link, p Pointer) error
+}
+
+// BasicTransferAdapter implements the "basic" adapter.
+type BasicTransferAdapter struct {
+ client *http.Client
+}
+
+// Name returns the name of the adapter.
+func (a *BasicTransferAdapter) Name() string {
+ return "basic"
+}
+
+// Download reads the download location and downloads the data.
+func (a *BasicTransferAdapter) Download(ctx context.Context, l *Link) (io.ReadCloser, error) {
+ req, err := createRequest(ctx, http.MethodGet, l.Href, l.Header, nil)
+ if err != nil {
+ return nil, err
+ }
+ log.Debug("Download Request: %+v", req)
+ resp, err := performRequest(ctx, a.client, req)
+ if err != nil {
+ return nil, err
+ }
+ return resp.Body, nil
+}
+
+// Upload sends the content to the LFS server.
+func (a *BasicTransferAdapter) Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error {
+ req, err := createRequest(ctx, http.MethodPut, l.Href, l.Header, r)
+ if err != nil {
+ return err
+ }
+ if req.Header.Get("Content-Type") == "" {
+ req.Header.Set("Content-Type", "application/octet-stream")
+ }
+ if req.Header.Get("Transfer-Encoding") == "chunked" {
+ req.TransferEncoding = []string{"chunked"}
+ }
+ req.ContentLength = p.Size
+
+ res, err := performRequest(ctx, a.client, req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ return nil
+}
+
+// Verify calls the verify handler on the LFS server
+func (a *BasicTransferAdapter) Verify(ctx context.Context, l *Link, p Pointer) error {
+ b, err := json.Marshal(p)
+ if err != nil {
+ log.Error("Error encoding json: %v", err)
+ return err
+ }
+
+ req, err := createRequest(ctx, http.MethodPost, l.Href, l.Header, bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", MediaType)
+ res, err := performRequest(ctx, a.client, req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ return nil
+}
diff --git a/modules/lfs/transferadapter_test.go b/modules/lfs/transferadapter_test.go
new file mode 100644
index 0000000..0766e4a
--- /dev/null
+++ b/modules/lfs/transferadapter_test.go
@@ -0,0 +1,172 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lfs
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBasicTransferAdapterName(t *testing.T) {
+ a := &BasicTransferAdapter{}
+
+ assert.Equal(t, "basic", a.Name())
+}
+
+func TestBasicTransferAdapter(t *testing.T) {
+ p := Pointer{Oid: "b5a2c96250612366ea272ffac6d9744aaf4b45aacd96aa7cfcb931ee3b558259", Size: 5}
+
+ roundTripHandler := func(req *http.Request) *http.Response {
+ assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
+ assert.Equal(t, "test-value", req.Header.Get("test-header"))
+
+ url := req.URL.String()
+ if strings.Contains(url, "download-request") {
+ assert.Equal(t, "GET", req.Method)
+
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString("dummy"))}
+ } else if strings.Contains(url, "upload-request") {
+ assert.Equal(t, "PUT", req.Method)
+ assert.Equal(t, "application/octet-stream", req.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ assert.Equal(t, "dummy", string(b))
+
+ return &http.Response{StatusCode: http.StatusOK}
+ } else if strings.Contains(url, "verify-request") {
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, MediaType, req.Header.Get("Content-Type"))
+
+ var vp Pointer
+ err := json.NewDecoder(req.Body).Decode(&vp)
+ require.NoError(t, err)
+ assert.Equal(t, p.Oid, vp.Oid)
+ assert.Equal(t, p.Size, vp.Size)
+
+ return &http.Response{StatusCode: http.StatusOK}
+ } else if strings.Contains(url, "error-response") {
+ er := &ErrorResponse{
+ Message: "Object not found",
+ }
+ payload := new(bytes.Buffer)
+ json.NewEncoder(payload).Encode(er)
+
+ return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(payload)}
+ }
+ t.Errorf("Unknown test case: %s", url)
+ return nil
+ }
+
+ hc := &http.Client{Transport: RoundTripFunc(roundTripHandler)}
+ a := &BasicTransferAdapter{hc}
+
+ t.Run("Download", func(t *testing.T) {
+ cases := []struct {
+ link *Link
+ expectederror string
+ }{
+ // case 0
+ {
+ link: &Link{
+ Href: "https://download-request.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "",
+ },
+ // case 1
+ {
+ link: &Link{
+ Href: "https://error-response.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "Object not found",
+ },
+ }
+
+ for n, c := range cases {
+ _, err := a.Download(context.Background(), c.link)
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ require.NoError(t, err, "case %d", n)
+ }
+ }
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ cases := []struct {
+ link *Link
+ expectederror string
+ }{
+ // case 0
+ {
+ link: &Link{
+ Href: "https://upload-request.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "",
+ },
+ // case 1
+ {
+ link: &Link{
+ Href: "https://error-response.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "Object not found",
+ },
+ }
+
+ for n, c := range cases {
+ err := a.Upload(context.Background(), c.link, p, bytes.NewBufferString("dummy"))
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ require.NoError(t, err, "case %d", n)
+ }
+ }
+ })
+
+ t.Run("Verify", func(t *testing.T) {
+ cases := []struct {
+ link *Link
+ expectederror string
+ }{
+ // case 0
+ {
+ link: &Link{
+ Href: "https://verify-request.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "",
+ },
+ // case 1
+ {
+ link: &Link{
+ Href: "https://error-response.io",
+ Header: map[string]string{"test-header": "test-value"},
+ },
+ expectederror: "Object not found",
+ },
+ }
+
+ for n, c := range cases {
+ err := a.Verify(context.Background(), c.link, p)
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ require.NoError(t, err, "case %d", n)
+ }
+ }
+ })
+}
diff --git a/modules/log/color.go b/modules/log/color.go
new file mode 100644
index 0000000..dcbba5f
--- /dev/null
+++ b/modules/log/color.go
@@ -0,0 +1,115 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "fmt"
+ "strconv"
+)
+
+const escape = "\033"
+
+// ColorAttribute defines a single SGR Code
+type ColorAttribute int
+
+// Base ColorAttributes
+const (
+ Reset ColorAttribute = iota
+ Bold
+ Faint
+ Italic
+ Underline
+ BlinkSlow
+ BlinkRapid
+ ReverseVideo
+ Concealed
+ CrossedOut
+)
+
+// Foreground text colors
+const (
+ FgBlack ColorAttribute = iota + 30
+ FgRed
+ FgGreen
+ FgYellow
+ FgBlue
+ FgMagenta
+ FgCyan
+ FgWhite
+)
+
+// Foreground Hi-Intensity text colors
+const (
+ FgHiBlack ColorAttribute = iota + 90
+ FgHiRed
+ FgHiGreen
+ FgHiYellow
+ FgHiBlue
+ FgHiMagenta
+ FgHiCyan
+ FgHiWhite
+)
+
+// Background text colors
+const (
+ BgBlack ColorAttribute = iota + 40
+ BgRed
+ BgGreen
+ BgYellow
+ BgBlue
+ BgMagenta
+ BgCyan
+ BgWhite
+)
+
+// Background Hi-Intensity text colors
+const (
+ BgHiBlack ColorAttribute = iota + 100
+ BgHiRed
+ BgHiGreen
+ BgHiYellow
+ BgHiBlue
+ BgHiMagenta
+ BgHiCyan
+ BgHiWhite
+)
+
+var (
+ resetBytes = ColorBytes(Reset)
+ fgCyanBytes = ColorBytes(FgCyan)
+ fgGreenBytes = ColorBytes(FgGreen)
+)
+
+type ColoredValue struct {
+ v any
+ colors []ColorAttribute
+}
+
+func (c *ColoredValue) Format(f fmt.State, verb rune) {
+ _, _ = f.Write(ColorBytes(c.colors...))
+ s := fmt.Sprintf(fmt.FormatString(f, verb), c.v)
+ _, _ = f.Write([]byte(s))
+ _, _ = f.Write(resetBytes)
+}
+
+func NewColoredValue(v any, color ...ColorAttribute) *ColoredValue {
+ return &ColoredValue{v: v, colors: color}
+}
+
+// ColorBytes converts a list of ColorAttributes to a byte array
+func ColorBytes(attrs ...ColorAttribute) []byte {
+ bytes := make([]byte, 0, 20)
+ bytes = append(bytes, escape[0], '[')
+ if len(attrs) > 0 {
+ bytes = append(bytes, strconv.Itoa(int(attrs[0]))...)
+ for _, a := range attrs[1:] {
+ bytes = append(bytes, ';')
+ bytes = append(bytes, strconv.Itoa(int(a))...)
+ }
+ } else {
+ bytes = append(bytes, strconv.Itoa(int(Bold))...)
+ }
+ bytes = append(bytes, 'm')
+ return bytes
+}
diff --git a/modules/log/color_console.go b/modules/log/color_console.go
new file mode 100644
index 0000000..82b5ce1
--- /dev/null
+++ b/modules/log/color_console.go
@@ -0,0 +1,17 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+// CanColorStdout reports if we can use ANSI escape sequences on stdout
+var CanColorStdout = true
+
+// CanColorStderr reports if we can use ANSI escape sequences on stderr
+var CanColorStderr = true
+
+// JournaldOnStdout reports whether stdout is attached to journald
+var JournaldOnStdout = false
+
+// JournaldOnStderr reports whether stderr is attached to journald
+var JournaldOnStderr = false
diff --git a/modules/log/color_console_other.go b/modules/log/color_console_other.go
new file mode 100644
index 0000000..c08b38c
--- /dev/null
+++ b/modules/log/color_console_other.go
@@ -0,0 +1,69 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package log
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "github.com/mattn/go-isatty"
+)
+
+func journaldDevIno() (uint64, uint64, bool) {
+ journaldStream := os.Getenv("JOURNAL_STREAM")
+ if len(journaldStream) == 0 {
+ return 0, 0, false
+ }
+ deviceStr, inodeStr, ok := strings.Cut(journaldStream, ":")
+ device, err1 := strconv.ParseUint(deviceStr, 10, 64)
+ inode, err2 := strconv.ParseUint(inodeStr, 10, 64)
+ if !ok || err1 != nil || err2 != nil {
+ return 0, 0, false
+ }
+ return device, inode, true
+}
+
+func fileStatDevIno(file *os.File) (uint64, uint64, bool) {
+ info, err := file.Stat()
+ if err != nil {
+ return 0, 0, false
+ }
+
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, 0, false
+ }
+
+ // Do a type conversion to uint64, because Dev isn't always uint64
+ // on every operating system + architecture combination.
+ return uint64(stat.Dev), stat.Ino, true //nolint:unconvert
+}
+
+func fileIsDevIno(file *os.File, dev, ino uint64) bool {
+ fileDev, fileIno, ok := fileStatDevIno(file)
+ return ok && dev == fileDev && ino == fileIno
+}
+
+func init() {
+ // When forgejo is running under service supervisor (e.g. systemd) with logging
+ // set to console, the output streams are typically captured into some logging
+ // system (e.g. journald or syslog) instead of going to the terminal. Disable
+ // usage of ANSI escape sequences if that's the case to avoid spamming
+ // the journal or syslog with garbled mess e.g. `#033[0m#033[32mcmd/web.go:102:#033[32m`.
+ CanColorStdout = isatty.IsTerminal(os.Stdout.Fd())
+ CanColorStderr = isatty.IsTerminal(os.Stderr.Fd())
+
+ // Furthermore, check if we are running under journald specifically so that
+ // further output adjustments can be applied. Specifically, this changes
+ // the console logger defaults to disable duplication of date/time info and
+ // enable emission of special control sequences understood by journald
+ // instead of ANSI colors.
+ journalDev, journalIno, ok := journaldDevIno()
+ JournaldOnStdout = ok && !CanColorStdout && fileIsDevIno(os.Stdout, journalDev, journalIno)
+ JournaldOnStderr = ok && !CanColorStderr && fileIsDevIno(os.Stderr, journalDev, journalIno)
+}
diff --git a/modules/log/color_console_windows.go b/modules/log/color_console_windows.go
new file mode 100644
index 0000000..3f59e93
--- /dev/null
+++ b/modules/log/color_console_windows.go
@@ -0,0 +1,42 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "os"
+
+ "github.com/mattn/go-isatty"
+ "golang.org/x/sys/windows"
+)
+
+func enableVTMode(console windows.Handle) bool {
+ mode := uint32(0)
+ err := windows.GetConsoleMode(console, &mode)
+ if err != nil {
+ return false
+ }
+
+ // EnableVirtualTerminalProcessing is the console mode to allow ANSI code
+ // interpretation on the console. See:
+ // https://docs.microsoft.com/en-us/windows/console/setconsolemode
+ // It only works on Windows 10. Earlier terminals will fail with an err which we will
+ // handle to say don't color
+ mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
+ err = windows.SetConsoleMode(console, mode)
+ return err == nil
+}
+
+func init() {
+ if isatty.IsTerminal(os.Stdout.Fd()) {
+ CanColorStdout = enableVTMode(windows.Stdout)
+ } else {
+ CanColorStdout = isatty.IsCygwinTerminal(os.Stderr.Fd())
+ }
+
+ if isatty.IsTerminal(os.Stderr.Fd()) {
+ CanColorStderr = enableVTMode(windows.Stderr)
+ } else {
+ CanColorStderr = isatty.IsCygwinTerminal(os.Stderr.Fd())
+ }
+}
diff --git a/modules/log/color_router.go b/modules/log/color_router.go
new file mode 100644
index 0000000..80e7e02
--- /dev/null
+++ b/modules/log/color_router.go
@@ -0,0 +1,87 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "fmt"
+ "time"
+)
+
+var statusToColor = map[int][]ColorAttribute{
+ 100: {Bold},
+ 200: {FgGreen},
+ 300: {FgYellow},
+ 304: {FgCyan},
+ 400: {Bold, FgRed},
+ 401: {Bold, FgMagenta},
+ 403: {Bold, FgMagenta},
+ 500: {Bold, BgRed},
+}
+
+// ColoredStatus adds colors for HTTP status
+func ColoredStatus(status int, s ...string) *ColoredValue {
+ color, ok := statusToColor[status]
+ if !ok {
+ color, ok = statusToColor[(status/100)*100]
+ }
+ if !ok {
+ color = []ColorAttribute{Bold}
+ }
+ if len(s) > 0 {
+ return NewColoredValue(s[0], color...)
+ }
+ return NewColoredValue(status, color...)
+}
+
+var methodToColor = map[string][]ColorAttribute{
+ "GET": {FgBlue},
+ "POST": {FgGreen},
+ "DELETE": {FgRed},
+ "PATCH": {FgCyan},
+ "PUT": {FgYellow, Faint},
+ "HEAD": {FgBlue, Faint},
+}
+
+// ColoredMethod adds colors for HTTP methods on log
+func ColoredMethod(method string) *ColoredValue {
+ color, ok := methodToColor[method]
+ if !ok {
+ return NewColoredValue(method, Bold)
+ }
+ return NewColoredValue(method, color...)
+}
+
+var (
+ durations = []time.Duration{
+ 10 * time.Millisecond,
+ 100 * time.Millisecond,
+ 1 * time.Second,
+ 5 * time.Second,
+ 10 * time.Second,
+ }
+
+ durationColors = [][]ColorAttribute{
+ {FgGreen},
+ {Bold},
+ {FgYellow},
+ {FgRed, Bold},
+ {BgRed},
+ }
+
+ wayTooLong = BgMagenta
+)
+
+// ColoredTime converts the provided time to a ColoredValue for logging. The duration is always formatted in milliseconds.
+func ColoredTime(duration time.Duration) *ColoredValue {
+ // the output of duration in Millisecond is more readable:
+ // * before: "100.1ms" "100.1μs" "100.1s"
+ // * better: "100.1ms" "0.1ms" "100100.0ms", readers can compare the values at first glance.
+ str := fmt.Sprintf("%.1fms", float64(duration.Microseconds())/1000)
+ for i, k := range durations {
+ if duration < k {
+ return NewColoredValue(str, durationColors[i]...)
+ }
+ }
+ return NewColoredValue(str, wayTooLong)
+}
diff --git a/modules/log/event_format.go b/modules/log/event_format.go
new file mode 100644
index 0000000..df6b083
--- /dev/null
+++ b/modules/log/event_format.go
@@ -0,0 +1,253 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "time"
+)
+
+type Event struct {
+ Time time.Time
+
+ GoroutinePid string
+ Caller string
+ Filename string
+ Line int
+
+ Level Level
+
+ MsgSimpleText string
+
+ msgFormat string // the format and args is only valid in the caller's goroutine
+ msgArgs []any // they are discarded before the event is passed to the writer's channel
+
+ Stacktrace string
+}
+
+type EventFormatted struct {
+ Origin *Event
+ Msg any // the message formatted by the writer's formatter, the writer knows its type
+}
+
+type EventFormatter func(mode *WriterMode, event *Event, msgFormat string, msgArgs ...any) []byte
+
+type logStringFormatter struct {
+ v LogStringer
+}
+
+var _ fmt.Formatter = logStringFormatter{}
+
+func (l logStringFormatter) Format(f fmt.State, verb rune) {
+ if f.Flag('#') && verb == 'v' {
+ _, _ = fmt.Fprintf(f, "%#v", l.v)
+ return
+ }
+ _, _ = f.Write([]byte(l.v.LogString()))
+}
+
+// Copy of cheap integer to fixed-width decimal to ascii from logger.
+// TODO: legacy bugs: doesn't support negative number, overflow if wid it too large.
+func itoa(buf []byte, i, wid int) []byte {
+ var s [20]byte
+ bp := len(s) - 1
+ for i >= 10 || wid > 1 {
+ wid--
+ q := i / 10
+ s[bp] = byte('0' + i - q*10)
+ bp--
+ i = q
+ }
+ // i < 10
+ s[bp] = byte('0' + i)
+ return append(buf, s[bp:]...)
+}
+
+func colorSprintf(colorize bool, format string, args ...any) string {
+ hasColorValue := false
+ for _, v := range args {
+ if _, hasColorValue = v.(*ColoredValue); hasColorValue {
+ break
+ }
+ }
+ if colorize || !hasColorValue {
+ return fmt.Sprintf(format, args...)
+ }
+
+ noColors := make([]any, len(args))
+ copy(noColors, args)
+ for i, v := range args {
+ if cv, ok := v.(*ColoredValue); ok {
+ noColors[i] = cv.v
+ }
+ }
+ return fmt.Sprintf(format, noColors...)
+}
+
+// EventFormatTextMessage makes the log message for a writer with its mode. This function is a copy of the original package
+func EventFormatTextMessage(mode *WriterMode, event *Event, msgFormat string, msgArgs ...any) []byte {
+ buf := make([]byte, 0, 1024)
+ t := event.Time
+ flags := mode.Flags.Bits()
+
+ // if log level prefixes are enabled, the message must begin with the prefix, see sd_daemon(3)
+ // "A line that is not prefixed will be logged at the default log level SD_INFO"
+ if flags&Llevelprefix != 0 {
+ prefix := event.Level.JournalPrefix()
+ buf = append(buf, prefix...)
+ }
+
+ buf = append(buf, mode.Prefix...)
+ if flags&(Ldate|Ltime|Lmicroseconds) != 0 {
+ if mode.Colorize {
+ buf = append(buf, fgCyanBytes...)
+ }
+ if flags&LUTC != 0 {
+ t = t.UTC()
+ }
+ if flags&Ldate != 0 {
+ year, month, day := t.Date()
+ buf = itoa(buf, year, 4)
+ buf = append(buf, '/')
+ buf = itoa(buf, int(month), 2)
+ buf = append(buf, '/')
+ buf = itoa(buf, day, 2)
+ buf = append(buf, ' ')
+ }
+ if flags&(Ltime|Lmicroseconds) != 0 {
+ hour, min, sec := t.Clock()
+ buf = itoa(buf, hour, 2)
+ buf = append(buf, ':')
+ buf = itoa(buf, min, 2)
+ buf = append(buf, ':')
+ buf = itoa(buf, sec, 2)
+ if flags&Lmicroseconds != 0 {
+ buf = append(buf, '.')
+ buf = itoa(buf, t.Nanosecond()/1e3, 6)
+ }
+ buf = append(buf, ' ')
+ }
+ if mode.Colorize {
+ buf = append(buf, resetBytes...)
+ }
+ }
+ if flags&(Lshortfile|Llongfile) != 0 {
+ if mode.Colorize {
+ buf = append(buf, fgGreenBytes...)
+ }
+ file := event.Filename
+ if flags&Lmedfile == Lmedfile {
+ startIndex := len(file) - 20
+ if startIndex > 0 {
+ file = "..." + file[startIndex:]
+ }
+ } else if flags&Lshortfile != 0 {
+ startIndex := strings.LastIndexByte(file, '/')
+ if startIndex > 0 && startIndex < len(file) {
+ file = file[startIndex+1:]
+ }
+ }
+ buf = append(buf, file...)
+ buf = append(buf, ':')
+ buf = itoa(buf, event.Line, -1)
+ if flags&(Lfuncname|Lshortfuncname) != 0 {
+ buf = append(buf, ':')
+ } else {
+ if mode.Colorize {
+ buf = append(buf, resetBytes...)
+ }
+ buf = append(buf, ' ')
+ }
+ }
+ if flags&(Lfuncname|Lshortfuncname) != 0 {
+ if mode.Colorize {
+ buf = append(buf, fgGreenBytes...)
+ }
+ funcname := event.Caller
+ if flags&Lshortfuncname != 0 {
+ lastIndex := strings.LastIndexByte(funcname, '.')
+ if lastIndex > 0 && len(funcname) > lastIndex+1 {
+ funcname = funcname[lastIndex+1:]
+ }
+ }
+ buf = append(buf, funcname...)
+ if mode.Colorize {
+ buf = append(buf, resetBytes...)
+ }
+ buf = append(buf, ' ')
+ }
+
+ if flags&(Llevel|Llevelinitial) != 0 {
+ level := strings.ToUpper(event.Level.String())
+ if mode.Colorize {
+ buf = append(buf, ColorBytes(levelToColor[event.Level]...)...)
+ }
+ buf = append(buf, '[')
+ if flags&Llevelinitial != 0 {
+ buf = append(buf, level[0])
+ } else {
+ buf = append(buf, level...)
+ }
+ buf = append(buf, ']')
+ if mode.Colorize {
+ buf = append(buf, resetBytes...)
+ }
+ buf = append(buf, ' ')
+ }
+
+ var msg []byte
+
+ // if the log needs colorizing, do it
+ if mode.Colorize && len(msgArgs) > 0 {
+ hasColorValue := false
+ for _, v := range msgArgs {
+ if _, hasColorValue = v.(*ColoredValue); hasColorValue {
+ break
+ }
+ }
+ if hasColorValue {
+ msg = []byte(fmt.Sprintf(msgFormat, msgArgs...))
+ }
+ }
+ // try to reuse the pre-formatted simple text message
+ if len(msg) == 0 {
+ msg = []byte(event.MsgSimpleText)
+ }
+ // if still no message, do the normal Sprintf for the message
+ if len(msg) == 0 {
+ msg = []byte(colorSprintf(mode.Colorize, msgFormat, msgArgs...))
+ }
+ // remove at most one trailing new line
+ if len(msg) > 0 && msg[len(msg)-1] == '\n' {
+ msg = msg[:len(msg)-1]
+ }
+
+ if flags&Lgopid == Lgopid {
+ if event.GoroutinePid != "" {
+ buf = append(buf, '[')
+ if mode.Colorize {
+ buf = append(buf, ColorBytes(FgHiYellow)...)
+ }
+ buf = append(buf, event.GoroutinePid...)
+ if mode.Colorize {
+ buf = append(buf, resetBytes...)
+ }
+ buf = append(buf, ']', ' ')
+ }
+ }
+ buf = append(buf, msg...)
+
+ if event.Stacktrace != "" && mode.StacktraceLevel <= event.Level {
+ lines := bytes.Split([]byte(event.Stacktrace), []byte("\n"))
+ for _, line := range lines {
+ buf = append(buf, "\n\t"...)
+ buf = append(buf, line...)
+ }
+ buf = append(buf, '\n')
+ }
+ buf = append(buf, '\n')
+ return buf
+}
diff --git a/modules/log/event_format_test.go b/modules/log/event_format_test.go
new file mode 100644
index 0000000..0c6061e
--- /dev/null
+++ b/modules/log/event_format_test.go
@@ -0,0 +1,114 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestItoa(t *testing.T) {
+ b := itoa(nil, 0, 0)
+ assert.Equal(t, "0", string(b))
+
+ b = itoa(nil, 0, 1)
+ assert.Equal(t, "0", string(b))
+
+ b = itoa(nil, 0, 2)
+ assert.Equal(t, "00", string(b))
+}
+
+func TestEventFormatTextMessage(t *testing.T) {
+ res := EventFormatTextMessage(&WriterMode{Prefix: "[PREFIX] ", Colorize: false, Flags: Flags{defined: true, flags: 0xffffffff}},
+ &Event{
+ Time: time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+ Caller: "caller",
+ Filename: "filename",
+ Line: 123,
+ GoroutinePid: "pid",
+ Level: ERROR,
+ Stacktrace: "stacktrace",
+ },
+ "msg format: %v %v", "arg0", NewColoredValue("arg1", FgBlue),
+ )
+
+ assert.Equal(t, `<3>[PREFIX] 2020/01/02 03:04:05.000000 filename:123:caller [E] [pid] msg format: arg0 arg1
+ stacktrace
+
+`, string(res))
+
+ res = EventFormatTextMessage(&WriterMode{Prefix: "[PREFIX] ", Colorize: true, Flags: Flags{defined: true, flags: 0xffffffff}},
+ &Event{
+ Time: time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+ Caller: "caller",
+ Filename: "filename",
+ Line: 123,
+ GoroutinePid: "pid",
+ Level: ERROR,
+ Stacktrace: "stacktrace",
+ },
+ "msg format: %v %v", "arg0", NewColoredValue("arg1", FgBlue),
+ )
+
+ assert.Equal(t, "<3>[PREFIX] \x1b[36m2020/01/02 03:04:05.000000 \x1b[0m\x1b[32mfilename:123:\x1b[32mcaller\x1b[0m \x1b[1;31m[E]\x1b[0m [\x1b[93mpid\x1b[0m] msg format: arg0 \x1b[34marg1\x1b[0m\n\tstacktrace\n\n", string(res))
+}
+
+func TestEventFormatTextMessageStd(t *testing.T) {
+ res := EventFormatTextMessage(&WriterMode{Prefix: "[PREFIX] ", Colorize: false, Flags: Flags{defined: true, flags: LstdFlags}},
+ &Event{
+ Time: time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+ Caller: "caller",
+ Filename: "filename",
+ Line: 123,
+ GoroutinePid: "pid",
+ Level: ERROR,
+ Stacktrace: "stacktrace",
+ },
+ "msg format: %v %v", "arg0", NewColoredValue("arg1", FgBlue),
+ )
+
+ assert.Equal(t, `[PREFIX] 2020/01/02 03:04:05 filename:123:caller [E] msg format: arg0 arg1
+ stacktrace
+
+`, string(res))
+
+ res = EventFormatTextMessage(&WriterMode{Prefix: "[PREFIX] ", Colorize: true, Flags: Flags{defined: true, flags: LstdFlags}},
+ &Event{
+ Time: time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+ Caller: "caller",
+ Filename: "filename",
+ Line: 123,
+ GoroutinePid: "pid",
+ Level: ERROR,
+ Stacktrace: "stacktrace",
+ },
+ "msg format: %v %v", "arg0", NewColoredValue("arg1", FgBlue),
+ )
+
+ assert.Equal(t, "[PREFIX] \x1b[36m2020/01/02 03:04:05 \x1b[0m\x1b[32mfilename:123:\x1b[32mcaller\x1b[0m \x1b[1;31m[E]\x1b[0m msg format: arg0 \x1b[34marg1\x1b[0m\n\tstacktrace\n\n", string(res))
+}
+
+func TestEventFormatTextMessageJournal(t *testing.T) {
+ // TODO: it makes no sense to emit \n-containing messages to journal as they will get mangled
+ // the proper way here is to attach the backtrace as structured metadata, but we can't do that via stderr
+ res := EventFormatTextMessage(&WriterMode{Prefix: "[PREFIX] ", Colorize: false, Flags: Flags{defined: true, flags: LjournaldFlags}},
+ &Event{
+ Time: time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+ Caller: "caller",
+ Filename: "filename",
+ Line: 123,
+ GoroutinePid: "pid",
+ Level: ERROR,
+ Stacktrace: "stacktrace",
+ },
+ "msg format: %v %v", "arg0", NewColoredValue("arg1", FgBlue),
+ )
+
+ assert.Equal(t, `<3>[PREFIX] msg format: arg0 arg1
+ stacktrace
+
+`, string(res))
+}
diff --git a/modules/log/event_writer.go b/modules/log/event_writer.go
new file mode 100644
index 0000000..4b77e48
--- /dev/null
+++ b/modules/log/event_writer.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "fmt"
+)
+
+// EventWriter is the general interface for all event writers
+// EventWriterBase is only used as its base interface
+// A writer implementation could override the default EventWriterBase functions
+// eg: a writer can override the Run to handle events in its own way with its own goroutine
+type EventWriter interface {
+ EventWriterBase
+}
+
+// WriterMode is the mode for creating a new EventWriter, it contains common options for all writers
+// Its WriterOption field is the specified options for a writer, it should be passed by value but not by pointer
+type WriterMode struct {
+ BufferLen int
+
+ Level Level
+ Prefix string
+ Colorize bool
+ Flags Flags
+
+ Expression string
+
+ StacktraceLevel Level
+
+ WriterOption any
+}
+
+// EventWriterProvider is the function for creating a new EventWriter
+type EventWriterProvider func(writerName string, writerMode WriterMode) EventWriter
+
+var eventWriterProviders = map[string]EventWriterProvider{}
+
+func RegisterEventWriter(writerType string, p EventWriterProvider) {
+ eventWriterProviders[writerType] = p
+}
+
+func HasEventWriter(writerType string) bool {
+ _, ok := eventWriterProviders[writerType]
+ return ok
+}
+
+func NewEventWriter(name, writerType string, mode WriterMode) (EventWriter, error) {
+ if p, ok := eventWriterProviders[writerType]; ok {
+ return p(name, mode), nil
+ }
+ return nil, fmt.Errorf("unknown event writer type %q for writer %q", writerType, name)
+}
diff --git a/modules/log/event_writer_base.go b/modules/log/event_writer_base.go
new file mode 100644
index 0000000..c327c48
--- /dev/null
+++ b/modules/log/event_writer_base.go
@@ -0,0 +1,169 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "regexp"
+ "runtime/pprof"
+ "time"
+)
+
+// EventWriterBase is the base interface for most event writers
+// It provides default implementations for most methods
+type EventWriterBase interface {
+ Base() *EventWriterBaseImpl
+ GetWriterType() string
+ GetWriterName() string
+ GetLevel() Level
+
+ Run(ctx context.Context)
+}
+
+type EventWriterBaseImpl struct {
+ writerType string
+
+ Name string
+ Mode *WriterMode
+ Queue chan *EventFormatted
+
+ FormatMessage EventFormatter // format the Event to a message and write it to output
+ OutputWriteCloser io.WriteCloser // it will be closed when the event writer is stopped
+ GetPauseChan func() chan struct{}
+
+ shared bool
+ stopped chan struct{}
+}
+
+var _ EventWriterBase = (*EventWriterBaseImpl)(nil)
+
+func (b *EventWriterBaseImpl) Base() *EventWriterBaseImpl {
+ return b
+}
+
+func (b *EventWriterBaseImpl) GetWriterType() string {
+ return b.writerType
+}
+
+func (b *EventWriterBaseImpl) GetWriterName() string {
+ return b.Name
+}
+
+func (b *EventWriterBaseImpl) GetLevel() Level {
+ return b.Mode.Level
+}
+
+// Run is the default implementation for EventWriter.Run
+func (b *EventWriterBaseImpl) Run(ctx context.Context) {
+ defer b.OutputWriteCloser.Close()
+
+ var exprRegexp *regexp.Regexp
+ if b.Mode.Expression != "" {
+ var err error
+ if exprRegexp, err = regexp.Compile(b.Mode.Expression); err != nil {
+ FallbackErrorf("unable to compile expression %q for writer %q: %v", b.Mode.Expression, b.Name, err)
+ }
+ }
+
+ handlePaused := func() {
+ if pause := b.GetPauseChan(); pause != nil {
+ select {
+ case <-pause:
+ case <-ctx.Done():
+ }
+ }
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case event, ok := <-b.Queue:
+ if !ok {
+ return
+ }
+
+ handlePaused()
+
+ if exprRegexp != nil {
+ fileLineCaller := fmt.Sprintf("%s:%d:%s", event.Origin.Filename, event.Origin.Line, event.Origin.Caller)
+ matched := exprRegexp.MatchString(fileLineCaller) || exprRegexp.MatchString(event.Origin.MsgSimpleText)
+ if !matched {
+ continue
+ }
+ }
+
+ var err error
+ switch msg := event.Msg.(type) {
+ case string:
+ _, err = b.OutputWriteCloser.Write([]byte(msg))
+ case []byte:
+ _, err = b.OutputWriteCloser.Write(msg)
+ case io.WriterTo:
+ _, err = msg.WriteTo(b.OutputWriteCloser)
+ default:
+ _, err = b.OutputWriteCloser.Write([]byte(fmt.Sprint(msg)))
+ }
+ if err != nil {
+ FallbackErrorf("unable to write log message of %q (%v): %v", b.Name, err, event.Msg)
+ }
+ }
+ }
+}
+
+func NewEventWriterBase(name, writerType string, mode WriterMode) *EventWriterBaseImpl {
+ if mode.BufferLen == 0 {
+ mode.BufferLen = 1000
+ }
+ if mode.Level == UNDEFINED {
+ mode.Level = INFO
+ }
+ if mode.StacktraceLevel == UNDEFINED {
+ mode.StacktraceLevel = NONE
+ }
+ b := &EventWriterBaseImpl{
+ writerType: writerType,
+
+ Name: name,
+ Mode: &mode,
+ Queue: make(chan *EventFormatted, mode.BufferLen),
+
+ GetPauseChan: GetManager().GetPauseChan, // by default, use the global pause channel
+ FormatMessage: EventFormatTextMessage,
+ }
+ return b
+}
+
+// eventWriterStartGo use "go" to start an event worker's Run method
+func eventWriterStartGo(ctx context.Context, w EventWriter, shared bool) {
+ if w.Base().stopped != nil {
+ return // already started
+ }
+ w.Base().shared = shared
+ w.Base().stopped = make(chan struct{})
+
+ ctxDesc := "Logger: EventWriter: " + w.GetWriterName()
+ if shared {
+ ctxDesc = "Logger: EventWriter (shared): " + w.GetWriterName()
+ }
+ writerCtx, writerCancel := newProcessTypedContext(ctx, ctxDesc)
+ go func() {
+ defer writerCancel()
+ defer close(w.Base().stopped)
+ pprof.SetGoroutineLabels(writerCtx)
+ w.Run(writerCtx)
+ }()
+}
+
+// eventWriterStopWait stops an event writer and waits for it to finish flushing (with a timeout)
+func eventWriterStopWait(w EventWriter) {
+ close(w.Base().Queue)
+ select {
+ case <-w.Base().stopped:
+ case <-time.After(2 * time.Second):
+ FallbackErrorf("unable to stop log writer %q in time, skip", w.GetWriterName())
+ }
+}
diff --git a/modules/log/event_writer_conn.go b/modules/log/event_writer_conn.go
new file mode 100644
index 0000000..022206a
--- /dev/null
+++ b/modules/log/event_writer_conn.go
@@ -0,0 +1,111 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "io"
+ "net"
+)
+
+type WriterConnOption struct {
+ Addr string
+ Protocol string
+ Reconnect bool
+ ReconnectOnMsg bool
+}
+
+type eventWriterConn struct {
+ *EventWriterBaseImpl
+ connWriter connWriter
+}
+
+var _ EventWriter = (*eventWriterConn)(nil)
+
+func NewEventWriterConn(writerName string, writerMode WriterMode) EventWriter {
+ w := &eventWriterConn{EventWriterBaseImpl: NewEventWriterBase(writerName, "conn", writerMode)}
+ opt := writerMode.WriterOption.(WriterConnOption)
+ w.connWriter = connWriter{
+ ReconnectOnMsg: opt.ReconnectOnMsg,
+ Reconnect: opt.Reconnect,
+ Net: opt.Protocol,
+ Addr: opt.Addr,
+ }
+ w.OutputWriteCloser = &w.connWriter
+ return w
+}
+
+func init() {
+ RegisterEventWriter("conn", NewEventWriterConn)
+}
+
+// below is copied from old code
+
+type connWriter struct {
+ innerWriter io.WriteCloser
+
+ ReconnectOnMsg bool
+ Reconnect bool
+ Net string `json:"net"`
+ Addr string `json:"addr"`
+}
+
+var _ io.WriteCloser = (*connWriter)(nil)
+
+// Close the inner writer
+func (i *connWriter) Close() error {
+ if i.innerWriter != nil {
+ return i.innerWriter.Close()
+ }
+ return nil
+}
+
+// Write the data to the connection
+func (i *connWriter) Write(p []byte) (int, error) {
+ if i.neededConnectOnMsg() {
+ if err := i.connect(); err != nil {
+ return 0, err
+ }
+ }
+
+ if i.ReconnectOnMsg {
+ defer i.innerWriter.Close()
+ }
+
+ return i.innerWriter.Write(p)
+}
+
+func (i *connWriter) neededConnectOnMsg() bool {
+ if i.Reconnect {
+ i.Reconnect = false
+ return true
+ }
+
+ if i.innerWriter == nil {
+ return true
+ }
+
+ return i.ReconnectOnMsg
+}
+
+func (i *connWriter) connect() error {
+ if i.innerWriter != nil {
+ _ = i.innerWriter.Close()
+ i.innerWriter = nil
+ }
+
+ conn, err := net.Dial(i.Net, i.Addr)
+ if err != nil {
+ return err
+ }
+
+ if tcpConn, ok := conn.(*net.TCPConn); ok {
+ err = tcpConn.SetKeepAlive(true)
+ if err != nil {
+ return err
+ }
+ }
+
+ i.innerWriter = conn
+ return nil
+}
diff --git a/modules/log/event_writer_conn_test.go b/modules/log/event_writer_conn_test.go
new file mode 100644
index 0000000..de8694f
--- /dev/null
+++ b/modules/log/event_writer_conn_test.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func listenReadAndClose(t *testing.T, l net.Listener, expected string) {
+ conn, err := l.Accept()
+ require.NoError(t, err)
+ defer conn.Close()
+ written, err := io.ReadAll(conn)
+
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(written))
+}
+
+func TestConnLogger(t *testing.T) {
+ protocol := "tcp"
+ address := ":3099"
+
+ l, err := net.Listen(protocol, address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+
+ prefix := "TestPrefix "
+ level := INFO
+ flags := LstdFlags | LUTC | Lfuncname
+
+ logger := NewLoggerWithWriters(context.Background(), "test", NewEventWriterConn("test-conn", WriterMode{
+ Level: level,
+ Prefix: prefix,
+ Flags: FlagsFromBits(flags),
+ WriterOption: WriterConnOption{Addr: address, Protocol: protocol, Reconnect: true, ReconnectOnMsg: true},
+ }))
+
+ location, _ := time.LoadLocation("EST")
+
+ date := time.Date(2019, time.January, 13, 22, 3, 30, 15, location)
+
+ dateString := date.UTC().Format("2006/01/02 15:04:05")
+
+ event := Event{
+ Level: INFO,
+ MsgSimpleText: "TEST MSG",
+ Caller: "CALLER",
+ Filename: "FULL/FILENAME",
+ Line: 1,
+ Time: date,
+ }
+ expected := fmt.Sprintf("%s%s %s:%d:%s [%c] %s\n", prefix, dateString, event.Filename, event.Line, event.Caller, strings.ToUpper(event.Level.String())[0], event.MsgSimpleText)
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ listenReadAndClose(t, l, expected)
+ }()
+ logger.SendLogEvent(&event)
+ wg.Wait()
+
+ logger.Close()
+}
diff --git a/modules/log/event_writer_console.go b/modules/log/event_writer_console.go
new file mode 100644
index 0000000..78183de
--- /dev/null
+++ b/modules/log/event_writer_console.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "io"
+ "os"
+)
+
+type WriterConsoleOption struct {
+ Stderr bool
+}
+
+type eventWriterConsole struct {
+ *EventWriterBaseImpl
+}
+
+var _ EventWriter = (*eventWriterConsole)(nil)
+
+type nopCloser struct {
+ io.Writer
+}
+
+func (nopCloser) Close() error { return nil }
+
+func NewEventWriterConsole(name string, mode WriterMode) EventWriter {
+ w := &eventWriterConsole{EventWriterBaseImpl: NewEventWriterBase(name, "console", mode)}
+ opt := mode.WriterOption.(WriterConsoleOption)
+ if opt.Stderr {
+ w.OutputWriteCloser = nopCloser{os.Stderr}
+ } else {
+ w.OutputWriteCloser = nopCloser{os.Stdout}
+ }
+ return w
+}
+
+func init() {
+ RegisterEventWriter("console", NewEventWriterConsole)
+}
diff --git a/modules/log/event_writer_file.go b/modules/log/event_writer_file.go
new file mode 100644
index 0000000..fd73d7d
--- /dev/null
+++ b/modules/log/event_writer_file.go
@@ -0,0 +1,53 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "io"
+
+ "code.gitea.io/gitea/modules/util/rotatingfilewriter"
+)
+
+type WriterFileOption struct {
+ FileName string
+ MaxSize int64
+ LogRotate bool
+ DailyRotate bool
+ MaxDays int
+ Compress bool
+ CompressionLevel int
+}
+
+type eventWriterFile struct {
+ *EventWriterBaseImpl
+ fileWriter io.WriteCloser
+}
+
+var _ EventWriter = (*eventWriterFile)(nil)
+
+func NewEventWriterFile(name string, mode WriterMode) EventWriter {
+ w := &eventWriterFile{EventWriterBaseImpl: NewEventWriterBase(name, "file", mode)}
+ opt := mode.WriterOption.(WriterFileOption)
+ var err error
+ w.fileWriter, err = rotatingfilewriter.Open(opt.FileName, &rotatingfilewriter.Options{
+ Rotate: opt.LogRotate,
+ MaximumSize: opt.MaxSize,
+ RotateDaily: opt.DailyRotate,
+ KeepDays: opt.MaxDays,
+ Compress: opt.Compress,
+ CompressionLevel: opt.CompressionLevel,
+ })
+ if err != nil {
+ // if the log file can't be opened, what should it do? panic/exit? ignore logs? fallback to stderr?
+ // it seems that "fallback to stderr" is slightly better than others ....
+ FallbackErrorf("unable to open log file %q: %v", opt.FileName, err)
+ w.fileWriter = nopCloser{Writer: LoggerToWriter(FallbackErrorf)}
+ }
+ w.OutputWriteCloser = w.fileWriter
+ return w
+}
+
+func init() {
+ RegisterEventWriter("file", NewEventWriterFile)
+}
diff --git a/modules/log/flags.go b/modules/log/flags.go
new file mode 100644
index 0000000..cadf54f
--- /dev/null
+++ b/modules/log/flags.go
@@ -0,0 +1,138 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+// These flags define which text to prefix to each log entry generated
+// by the Logger. Bits are or'ed together to control what's printed.
+// There is no control over the order they appear (the order listed
+// here) or the format they present (as described in the comments).
+// The prefix is followed by a colon only if more than time is stated
+// is specified. For example, flags Ldate | Ltime
+// produce, 2009/01/23 01:23:23 message.
+// The standard is:
+// 2009/01/23 01:23:23 ...a/logger/c/d.go:23:runtime.Caller() [I]: message
+const (
+ Ldate uint32 = 1 << iota // the date in the local time zone: 2009/01/23
+ Ltime // the time in the local time zone: 01:23:23
+ Lmicroseconds // microsecond resolution: 01:23:23.123123. assumes Ltime.
+ Llongfile // full file name and line number: /a/logger/c/d.go:23
+ Lshortfile // final file name element and line number: d.go:23. overrides Llongfile
+ Lfuncname // function name of the caller: runtime.Caller()
+ Lshortfuncname // last part of the function name
+ LUTC // if Ldate or Ltime is set, use UTC rather than the local time zone
+ Llevelinitial // Initial character of the provided level in brackets, eg. [I] for info
+ Llevel // Provided level in brackets [INFO]
+ Lgopid // the Goroutine-PID of the context
+ Llevelprefix // printk-style logging prefixes as documented in sd-daemon(3), used by journald
+
+ Lmedfile = Lshortfile | Llongfile // last 20 characters of the filename
+ LstdFlags = Ldate | Ltime | Lmedfile | Lshortfuncname | Llevelinitial // default
+ LjournaldFlags = Llevelprefix
+)
+
+const Ldefault = LstdFlags
+
+type Flags struct {
+ defined bool
+ flags uint32
+}
+
+var flagFromString = map[string]uint32{
+ "date": Ldate,
+ "time": Ltime,
+ "microseconds": Lmicroseconds,
+ "longfile": Llongfile,
+ "shortfile": Lshortfile,
+ "funcname": Lfuncname,
+ "shortfuncname": Lshortfuncname,
+ "utc": LUTC,
+ "levelinitial": Llevelinitial,
+ "level": Llevel,
+ "levelprefix": Llevelprefix,
+ "gopid": Lgopid,
+
+ "medfile": Lmedfile,
+ "stdflags": LstdFlags,
+ "journaldflags": LjournaldFlags,
+}
+
+var flagComboToString = []struct {
+ flag uint32
+ name string
+}{
+ // name with more bits comes first
+ {LstdFlags, "stdflags"},
+ {Lmedfile, "medfile"},
+
+ {Ldate, "date"},
+ {Ltime, "time"},
+ {Lmicroseconds, "microseconds"},
+ {Llongfile, "longfile"},
+ {Lshortfile, "shortfile"},
+ {Lfuncname, "funcname"},
+ {Lshortfuncname, "shortfuncname"},
+ {LUTC, "utc"},
+ {Llevelinitial, "levelinitial"},
+ {Llevel, "level"},
+ {Lgopid, "gopid"},
+}
+
+func (f Flags) Bits() uint32 {
+ if !f.defined {
+ return Ldefault
+ }
+ return f.flags
+}
+
+func (f Flags) String() string {
+ flags := f.Bits()
+ var flagNames []string
+ for _, it := range flagComboToString {
+ if flags&it.flag == it.flag {
+ flags &^= it.flag
+ flagNames = append(flagNames, it.name)
+ }
+ }
+ if len(flagNames) == 0 {
+ return "none"
+ }
+ sort.Strings(flagNames)
+ return strings.Join(flagNames, ",")
+}
+
+func (f *Flags) UnmarshalJSON(bytes []byte) error {
+ var s string
+ if err := json.Unmarshal(bytes, &s); err != nil {
+ return err
+ }
+ *f = FlagsFromString(s)
+ return nil
+}
+
+func (f Flags) MarshalJSON() ([]byte, error) {
+ return []byte(`"` + f.String() + `"`), nil
+}
+
+func FlagsFromString(from string, def ...uint32) Flags {
+ from = strings.TrimSpace(from)
+ if from == "" && len(def) > 0 {
+ return Flags{defined: true, flags: def[0]}
+ }
+ flags := uint32(0)
+ for _, flag := range strings.Split(strings.ToLower(from), ",") {
+ flags |= flagFromString[strings.TrimSpace(flag)]
+ }
+ return Flags{defined: true, flags: flags}
+}
+
+func FlagsFromBits(flags uint32) Flags {
+ return Flags{defined: true, flags: flags}
+}
diff --git a/modules/log/flags_test.go b/modules/log/flags_test.go
new file mode 100644
index 0000000..a101c42
--- /dev/null
+++ b/modules/log/flags_test.go
@@ -0,0 +1,31 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFlags(t *testing.T) {
+ assert.EqualValues(t, Ldefault, Flags{}.Bits())
+ assert.EqualValues(t, 0, FlagsFromString("").Bits())
+ assert.EqualValues(t, Lgopid, FlagsFromString("", Lgopid).Bits())
+ assert.EqualValues(t, 0, FlagsFromString("none", Lgopid).Bits())
+ assert.EqualValues(t, Ldate|Ltime, FlagsFromString("date,time", Lgopid).Bits())
+
+ assert.EqualValues(t, "stdflags", FlagsFromString("stdflags").String())
+ assert.EqualValues(t, "medfile", FlagsFromString("medfile").String())
+
+ bs, err := json.Marshal(FlagsFromString("utc,level"))
+ require.NoError(t, err)
+ assert.EqualValues(t, `"level,utc"`, string(bs))
+ var flags Flags
+ require.NoError(t, json.Unmarshal(bs, &flags))
+ assert.EqualValues(t, LUTC|Llevel, flags.Bits())
+}
diff --git a/modules/log/groutinelabel.go b/modules/log/groutinelabel.go
new file mode 100644
index 0000000..56d7af4
--- /dev/null
+++ b/modules/log/groutinelabel.go
@@ -0,0 +1,19 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import "unsafe"
+
+//go:linkname runtime_getProfLabel runtime/pprof.runtime_getProfLabel
+func runtime_getProfLabel() unsafe.Pointer //nolint
+
+type labelMap map[string]string
+
+func getGoroutineLabels() map[string]string {
+ l := (*labelMap)(runtime_getProfLabel())
+ if l == nil {
+ return nil
+ }
+ return *l
+}
diff --git a/modules/log/groutinelabel_test.go b/modules/log/groutinelabel_test.go
new file mode 100644
index 0000000..34e9965
--- /dev/null
+++ b/modules/log/groutinelabel_test.go
@@ -0,0 +1,33 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "runtime/pprof"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_getGoroutineLabels(t *testing.T) {
+ pprof.Do(context.Background(), pprof.Labels(), func(ctx context.Context) {
+ currentLabels := getGoroutineLabels()
+ pprof.ForLabels(ctx, func(key, value string) bool {
+ assert.EqualValues(t, value, currentLabels[key])
+ return true
+ })
+
+ pprof.Do(ctx, pprof.Labels("Test_getGoroutineLabels", "Test_getGoroutineLabels_child1"), func(ctx context.Context) {
+ currentLabels := getGoroutineLabels()
+ pprof.ForLabels(ctx, func(key, value string) bool {
+ assert.EqualValues(t, value, currentLabels[key])
+ return true
+ })
+ if assert.NotNil(t, currentLabels) {
+ assert.EqualValues(t, "Test_getGoroutineLabels_child1", currentLabels["Test_getGoroutineLabels"])
+ }
+ })
+ })
+}
diff --git a/modules/log/init.go b/modules/log/init.go
new file mode 100644
index 0000000..3fb5200
--- /dev/null
+++ b/modules/log/init.go
@@ -0,0 +1,44 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "runtime"
+ "strings"
+
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/util/rotatingfilewriter"
+)
+
+var projectPackagePrefix string
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ projectPackagePrefix = strings.TrimSuffix(filename, "modules/log/init.go")
+ if projectPackagePrefix == filename {
+ // in case the source code file is moved, we can not trim the suffix, the code above should also be updated.
+ panic("unable to detect correct package prefix, please update file: " + filename)
+ }
+
+ rotatingfilewriter.ErrorPrintf = FallbackErrorf
+
+ process.TraceCallback = func(skip int, start bool, pid process.IDType, description string, parentPID process.IDType, typ string) {
+ if start && parentPID != "" {
+ Log(skip+1, TRACE, "Start %s: %s (from %s) (%s)", NewColoredValue(pid, FgHiYellow), description, NewColoredValue(parentPID, FgYellow), NewColoredValue(typ, Reset))
+ } else if start {
+ Log(skip+1, TRACE, "Start %s: %s (%s)", NewColoredValue(pid, FgHiYellow), description, NewColoredValue(typ, Reset))
+ } else {
+ Log(skip+1, TRACE, "Done %s: %s", NewColoredValue(pid, FgHiYellow), NewColoredValue(description, Reset))
+ }
+ }
+}
+
+func newProcessTypedContext(parent context.Context, desc string) (ctx context.Context, cancel context.CancelFunc) {
+ // the "process manager" also calls "log.Trace()" to output logs, so if we want to create new contexts by the manager, we need to disable the trace temporarily
+ process.TraceLogDisable(true)
+ defer process.TraceLogDisable(false)
+ ctx, _, cancel = process.GetManager().AddTypedContext(parent, desc, process.SystemProcessType, false)
+ return ctx, cancel
+}
diff --git a/modules/log/level.go b/modules/log/level.go
new file mode 100644
index 0000000..47f7b83
--- /dev/null
+++ b/modules/log/level.go
@@ -0,0 +1,136 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "bytes"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+// Level is the level of the logger
+type Level int
+
+const (
+ UNDEFINED Level = iota
+ TRACE
+ DEBUG
+ INFO
+ WARN
+ ERROR
+ FATAL
+ NONE
+)
+
+const CRITICAL = ERROR // most logger frameworks doesn't support CRITICAL, and it doesn't seem useful
+
+var toString = map[Level]string{
+ UNDEFINED: "undefined",
+
+ TRACE: "trace",
+ DEBUG: "debug",
+ INFO: "info",
+ WARN: "warn",
+ ERROR: "error",
+
+ FATAL: "fatal",
+ NONE: "none",
+}
+
+// Machine-readable log level prefixes as defined in sd-daemon(3).
+//
+// "If a systemd service definition file is configured with StandardError=journal
+// or StandardError=kmsg (and similar with StandardOutput=), these prefixes can
+// be used to encode a log level in lines printed. <...> To use these prefixes
+// simply prefix every line with one of these strings. A line that is not prefixed
+// will be logged at the default log level SD_INFO."
+var toJournalPrefix = map[Level]string{
+ TRACE: "<7>", // SD_DEBUG
+ DEBUG: "<6>", // SD_INFO
+ INFO: "<5>", // SD_NOTICE
+ WARN: "<4>", // SD_WARNING
+ ERROR: "<3>", // SD_ERR
+ FATAL: "<2>", // SD_CRIT
+}
+
+var toLevel = map[string]Level{
+ "undefined": UNDEFINED,
+
+ "trace": TRACE,
+ "debug": DEBUG,
+ "info": INFO,
+ "warn": WARN,
+ "warning": WARN,
+ "error": ERROR,
+
+ "fatal": FATAL,
+ "none": NONE,
+}
+
+var levelToColor = map[Level][]ColorAttribute{
+ TRACE: {Bold, FgCyan},
+ DEBUG: {Bold, FgBlue},
+ INFO: {Bold, FgGreen},
+ WARN: {Bold, FgYellow},
+ ERROR: {Bold, FgRed},
+ FATAL: {Bold, BgRed},
+ NONE: {Reset},
+}
+
+func (l Level) String() string {
+ s, ok := toString[l]
+ if ok {
+ return s
+ }
+ return "info"
+}
+
+func (l Level) JournalPrefix() string {
+ return toJournalPrefix[l]
+}
+
+func (l Level) ColorAttributes() []ColorAttribute {
+ color, ok := levelToColor[l]
+ if ok {
+ return color
+ }
+ none := levelToColor[NONE]
+ return none
+}
+
+// MarshalJSON takes a Level and turns it into text
+func (l Level) MarshalJSON() ([]byte, error) {
+ buffer := bytes.NewBufferString(`"`)
+ buffer.WriteString(toString[l])
+ buffer.WriteString(`"`)
+ return buffer.Bytes(), nil
+}
+
+// UnmarshalJSON takes text and turns it into a Level
+func (l *Level) UnmarshalJSON(b []byte) error {
+ var tmp any
+ err := json.Unmarshal(b, &tmp)
+ if err != nil {
+ return err
+ }
+
+ switch v := tmp.(type) {
+ case string:
+ *l = LevelFromString(v)
+ case int:
+ *l = LevelFromString(Level(v).String())
+ default:
+ *l = INFO
+ }
+ return nil
+}
+
+// LevelFromString takes a level string and returns a Level
+func LevelFromString(level string) Level {
+ if l, ok := toLevel[strings.ToLower(level)]; ok {
+ return l
+ }
+ return INFO
+}
diff --git a/modules/log/level_test.go b/modules/log/level_test.go
new file mode 100644
index 0000000..9831ca5
--- /dev/null
+++ b/modules/log/level_test.go
@@ -0,0 +1,56 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "fmt"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testLevel struct {
+ Level Level `json:"level"`
+}
+
+func TestLevelMarshalUnmarshalJSON(t *testing.T) {
+ levelBytes, err := json.Marshal(testLevel{
+ Level: INFO,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, string(makeTestLevelBytes(INFO.String())), string(levelBytes))
+
+ var testLevel testLevel
+ err = json.Unmarshal(levelBytes, &testLevel)
+ require.NoError(t, err)
+ assert.Equal(t, INFO, testLevel.Level)
+
+ err = json.Unmarshal(makeTestLevelBytes(`FOFOO`), &testLevel)
+ require.NoError(t, err)
+ assert.Equal(t, INFO, testLevel.Level)
+
+ err = json.Unmarshal([]byte(fmt.Sprintf(`{"level":%d}`, 2)), &testLevel)
+ require.NoError(t, err)
+ assert.Equal(t, INFO, testLevel.Level)
+
+ err = json.Unmarshal([]byte(fmt.Sprintf(`{"level":%d}`, 10012)), &testLevel)
+ require.NoError(t, err)
+ assert.Equal(t, INFO, testLevel.Level)
+
+ err = json.Unmarshal([]byte(`{"level":{}}`), &testLevel)
+ require.NoError(t, err)
+ assert.Equal(t, INFO, testLevel.Level)
+
+ assert.Equal(t, INFO.String(), Level(1001).String())
+
+ err = json.Unmarshal([]byte(`{"level":{}`), &testLevel.Level)
+ require.Error(t, err)
+}
+
+func makeTestLevelBytes(level string) []byte {
+ return []byte(fmt.Sprintf(`{"level":"%s"}`, level))
+}
diff --git a/modules/log/logger.go b/modules/log/logger.go
new file mode 100644
index 0000000..a833b6e
--- /dev/null
+++ b/modules/log/logger.go
@@ -0,0 +1,50 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package log provides logging capabilities for Gitea.
+// Concepts:
+//
+// * Logger: a Logger provides logging functions and dispatches log events to all its writers
+//
+// * EventWriter: written log Event to a destination (eg: file, console)
+// - EventWriterBase: the base struct of a writer, it contains common fields and functions for all writers
+// - WriterType: the type name of a writer, eg: console, file
+// - WriterName: aka Mode Name in document, the name of a writer instance, it's usually defined by the config file.
+// It is called "mode name" because old code use MODE as config key, to keep compatibility, keep this concept.
+//
+// * WriterMode: the common options for all writers, eg: log level.
+// - WriterConsoleOption and others: the specified options for a writer, eg: file path, remote address.
+//
+// Call graph:
+// -> log.Info()
+// -> LoggerImpl.Log()
+// -> LoggerImpl.SendLogEvent, then the event goes into writer's goroutines
+// -> EventWriter.Run() handles the events
+package log
+
+// BaseLogger provides the basic logging functions
+type BaseLogger interface {
+ Log(skip int, level Level, format string, v ...any)
+ GetLevel() Level
+}
+
+// LevelLogger provides level-related logging functions
+type LevelLogger interface {
+ LevelEnabled(level Level) bool
+
+ Trace(format string, v ...any)
+ Debug(format string, v ...any)
+ Info(format string, v ...any)
+ Warn(format string, v ...any)
+ Error(format string, v ...any)
+ Critical(format string, v ...any)
+}
+
+type Logger interface {
+ BaseLogger
+ LevelLogger
+}
+
+type LogStringer interface { //nolint:revive
+ LogString() string
+}
diff --git a/modules/log/logger_global.go b/modules/log/logger_global.go
new file mode 100644
index 0000000..994acfe
--- /dev/null
+++ b/modules/log/logger_global.go
@@ -0,0 +1,83 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "fmt"
+ "os"
+)
+
+// FallbackErrorf is the last chance to show an error if the logger has internal errors
+func FallbackErrorf(format string, args ...any) {
+ _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
+
+func GetLevel() Level {
+ return GetLogger(DEFAULT).GetLevel()
+}
+
+func Log(skip int, level Level, format string, v ...any) {
+ GetLogger(DEFAULT).Log(skip+1, level, format, v...)
+}
+
+func Trace(format string, v ...any) {
+ Log(1, TRACE, format, v...)
+}
+
+func IsTrace() bool {
+ return GetLevel() <= TRACE
+}
+
+func Debug(format string, v ...any) {
+ Log(1, DEBUG, format, v...)
+}
+
+func IsDebug() bool {
+ return GetLevel() <= DEBUG
+}
+
+func Info(format string, v ...any) {
+ Log(1, INFO, format, v...)
+}
+
+func Warn(format string, v ...any) {
+ Log(1, WARN, format, v...)
+}
+
+func Error(format string, v ...any) {
+ Log(1, ERROR, format, v...)
+}
+
+func ErrorWithSkip(skip int, format string, v ...any) {
+ Log(skip+1, ERROR, format, v...)
+}
+
+func Critical(format string, v ...any) {
+ Log(1, ERROR, format, v...)
+}
+
+// Fatal records fatal log and exit process
+func Fatal(format string, v ...any) {
+ Log(1, FATAL, format, v...)
+ GetManager().Close()
+ os.Exit(1)
+}
+
+func GetLogger(name string) Logger {
+ return GetManager().GetLogger(name)
+}
+
+func IsLoggerEnabled(name string) bool {
+ return GetManager().GetLogger(name).IsEnabled()
+}
+
+func SetConsoleLogger(loggerName, writerName string, level Level) {
+ writer := NewEventWriterConsole(writerName, WriterMode{
+ Level: level,
+ Flags: FlagsFromBits(LstdFlags),
+ Colorize: CanColorStdout,
+ WriterOption: WriterConsoleOption{},
+ })
+ GetManager().GetLogger(loggerName).ReplaceAllWriters(writer)
+}
diff --git a/modules/log/logger_impl.go b/modules/log/logger_impl.go
new file mode 100644
index 0000000..d38c651
--- /dev/null
+++ b/modules/log/logger_impl.go
@@ -0,0 +1,240 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type LoggerImpl struct {
+ LevelLogger
+
+ ctx context.Context
+ ctxCancel context.CancelFunc
+
+ level atomic.Int32
+ stacktraceLevel atomic.Int32
+
+ eventWriterMu sync.RWMutex
+ eventWriters map[string]EventWriter
+}
+
+var (
+ _ BaseLogger = (*LoggerImpl)(nil)
+ _ LevelLogger = (*LoggerImpl)(nil)
+)
+
+// SendLogEvent sends a log event to all writers
+func (l *LoggerImpl) SendLogEvent(event *Event) {
+ l.eventWriterMu.RLock()
+ defer l.eventWriterMu.RUnlock()
+
+ if len(l.eventWriters) == 0 {
+ FallbackErrorf("[no logger writer]: %s", event.MsgSimpleText)
+ return
+ }
+
+ // the writers have their own goroutines, the message arguments (with Stringer) shouldn't be used in other goroutines
+ // so the event message must be formatted here
+ msgFormat, msgArgs := event.msgFormat, event.msgArgs
+ event.msgFormat, event.msgArgs = "(already processed by formatters)", nil
+
+ for _, w := range l.eventWriters {
+ if event.Level < w.GetLevel() {
+ continue
+ }
+ formatted := &EventFormatted{
+ Origin: event,
+ Msg: w.Base().FormatMessage(w.Base().Mode, event, msgFormat, msgArgs...),
+ }
+ select {
+ case w.Base().Queue <- formatted:
+ default:
+ bs, _ := json.Marshal(event)
+ FallbackErrorf("log writer %q queue is full, event: %v", w.GetWriterName(), string(bs))
+ }
+ }
+}
+
+// syncLevelInternal syncs the level of the logger with the levels of the writers
+func (l *LoggerImpl) syncLevelInternal() {
+ lowestLevel := NONE
+ for _, w := range l.eventWriters {
+ if w.GetLevel() < lowestLevel {
+ lowestLevel = w.GetLevel()
+ }
+ }
+ l.level.Store(int32(lowestLevel))
+
+ lowestLevel = NONE
+ for _, w := range l.eventWriters {
+ if w.Base().Mode.StacktraceLevel < lowestLevel {
+ lowestLevel = w.GetLevel()
+ }
+ }
+ l.stacktraceLevel.Store(int32(lowestLevel))
+}
+
+// removeWriterInternal removes a writer from the logger, and stops it if it's not shared
+func (l *LoggerImpl) removeWriterInternal(w EventWriter) {
+ if !w.Base().shared {
+ eventWriterStopWait(w) // only stop non-shared writers, shared writers are managed by the manager
+ }
+ delete(l.eventWriters, w.GetWriterName())
+}
+
+// AddWriters adds writers to the logger, and starts them. Existing writers will be replaced by new ones.
+func (l *LoggerImpl) AddWriters(writer ...EventWriter) {
+ l.eventWriterMu.Lock()
+ defer l.eventWriterMu.Unlock()
+ l.addWritersInternal(writer...)
+}
+
+func (l *LoggerImpl) addWritersInternal(writer ...EventWriter) {
+ for _, w := range writer {
+ if old, ok := l.eventWriters[w.GetWriterName()]; ok {
+ l.removeWriterInternal(old)
+ }
+ }
+
+ for _, w := range writer {
+ l.eventWriters[w.GetWriterName()] = w
+ eventWriterStartGo(l.ctx, w, false)
+ }
+
+ l.syncLevelInternal()
+}
+
+// RemoveWriter removes a writer from the logger, and the writer is closed and flushed if it is not shared
+func (l *LoggerImpl) RemoveWriter(modeName string) error {
+ l.eventWriterMu.Lock()
+ defer l.eventWriterMu.Unlock()
+
+ w, ok := l.eventWriters[modeName]
+ if !ok {
+ return util.ErrNotExist
+ }
+
+ l.removeWriterInternal(w)
+ l.syncLevelInternal()
+ return nil
+}
+
+// ReplaceAllWriters replaces all writers from the logger, non-shared writers are closed and flushed
+func (l *LoggerImpl) ReplaceAllWriters(writer ...EventWriter) {
+ l.eventWriterMu.Lock()
+ defer l.eventWriterMu.Unlock()
+
+ for _, w := range l.eventWriters {
+ l.removeWriterInternal(w)
+ }
+ l.eventWriters = map[string]EventWriter{}
+ l.addWritersInternal(writer...)
+}
+
+// DumpWriters dumps the writers as a JSON map, it's used for debugging and display purposes.
+func (l *LoggerImpl) DumpWriters() map[string]any {
+ l.eventWriterMu.RLock()
+ defer l.eventWriterMu.RUnlock()
+
+ writers := make(map[string]any, len(l.eventWriters))
+ for k, w := range l.eventWriters {
+ bs, err := json.Marshal(w.Base().Mode)
+ if err != nil {
+ FallbackErrorf("marshal writer %q to dump failed: %v", k, err)
+ continue
+ }
+ m := map[string]any{}
+ _ = json.Unmarshal(bs, &m)
+ m["WriterType"] = w.GetWriterType()
+ writers[k] = m
+ }
+ return writers
+}
+
+// Close closes the logger, non-shared writers are closed and flushed
+func (l *LoggerImpl) Close() {
+ l.ReplaceAllWriters()
+ l.ctxCancel()
+}
+
+// IsEnabled returns true if the logger is enabled: it has a working level and has writers
+// Fatal is not considered as enabled, because it's a special case and the process just exits
+func (l *LoggerImpl) IsEnabled() bool {
+ l.eventWriterMu.RLock()
+ defer l.eventWriterMu.RUnlock()
+ return l.level.Load() < int32(FATAL) && len(l.eventWriters) > 0
+}
+
+// Log prepares the log event, if the level matches, the event will be sent to the writers
+func (l *LoggerImpl) Log(skip int, level Level, format string, logArgs ...any) {
+ if Level(l.level.Load()) > level {
+ return
+ }
+
+ event := &Event{
+ Time: time.Now(),
+ Level: level,
+ Caller: "?()",
+ }
+
+ pc, filename, line, ok := runtime.Caller(skip + 1)
+ if ok {
+ fn := runtime.FuncForPC(pc)
+ if fn != nil {
+ event.Caller = fn.Name() + "()"
+ }
+ }
+ event.Filename, event.Line = strings.TrimPrefix(filename, projectPackagePrefix), line
+
+ if l.stacktraceLevel.Load() <= int32(level) {
+ event.Stacktrace = Stack(skip + 1)
+ }
+
+ labels := getGoroutineLabels()
+ if labels != nil {
+ event.GoroutinePid = labels["pid"]
+ }
+
+ // get a simple text message without color
+ msgArgs := make([]any, len(logArgs))
+ copy(msgArgs, logArgs)
+
+ // handle LogStringer values
+ for i, v := range msgArgs {
+ if cv, ok := v.(*ColoredValue); ok {
+ if s, ok := cv.v.(LogStringer); ok {
+ cv.v = logStringFormatter{v: s}
+ }
+ } else if s, ok := v.(LogStringer); ok {
+ msgArgs[i] = logStringFormatter{v: s}
+ }
+ }
+
+ event.MsgSimpleText = colorSprintf(false, format, msgArgs...)
+ event.msgFormat = format
+ event.msgArgs = msgArgs
+ l.SendLogEvent(event)
+}
+
+func (l *LoggerImpl) GetLevel() Level {
+ return Level(l.level.Load())
+}
+
+func NewLoggerWithWriters(ctx context.Context, name string, writer ...EventWriter) *LoggerImpl {
+ l := &LoggerImpl{}
+ l.ctx, l.ctxCancel = newProcessTypedContext(ctx, "Logger: "+name)
+ l.LevelLogger = BaseLoggerToGeneralLogger(l)
+ l.eventWriters = map[string]EventWriter{}
+ l.AddWriters(writer...)
+ return l
+}
diff --git a/modules/log/logger_test.go b/modules/log/logger_test.go
new file mode 100644
index 0000000..0de14eb
--- /dev/null
+++ b/modules/log/logger_test.go
@@ -0,0 +1,146 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type dummyWriter struct {
+ *EventWriterBaseImpl
+
+ delay time.Duration
+
+ mu sync.Mutex
+ logs []string
+}
+
+func (d *dummyWriter) Write(p []byte) (n int, err error) {
+ if d.delay > 0 {
+ time.Sleep(d.delay)
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.logs = append(d.logs, string(p))
+ return len(p), nil
+}
+
+func (d *dummyWriter) Close() error {
+ return nil
+}
+
+func (d *dummyWriter) GetLogs() []string {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ logs := make([]string, len(d.logs))
+ copy(logs, d.logs)
+ return logs
+}
+
+func newDummyWriter(name string, level Level, delay time.Duration) *dummyWriter {
+ w := &dummyWriter{
+ EventWriterBaseImpl: NewEventWriterBase(name, "dummy", WriterMode{Level: level, Flags: FlagsFromBits(0)}),
+ }
+ w.delay = delay
+ w.Base().OutputWriteCloser = w
+ return w
+}
+
+func TestLogger(t *testing.T) {
+ logger := NewLoggerWithWriters(context.Background(), "test")
+
+ dump := logger.DumpWriters()
+ assert.Empty(t, dump)
+ assert.EqualValues(t, NONE, logger.GetLevel())
+ assert.False(t, logger.IsEnabled())
+
+ w1 := newDummyWriter("dummy-1", DEBUG, 0)
+ logger.AddWriters(w1)
+ assert.EqualValues(t, DEBUG, logger.GetLevel())
+
+ w2 := newDummyWriter("dummy-2", WARN, 200*time.Millisecond)
+ logger.AddWriters(w2)
+ assert.EqualValues(t, DEBUG, logger.GetLevel())
+
+ dump = logger.DumpWriters()
+ assert.Len(t, dump, 2)
+
+ logger.Trace("trace-level") // this level is not logged
+ logger.Debug("debug-level")
+ logger.Error("error-level")
+
+ // w2 is slow, so only w1 has logs
+ time.Sleep(100 * time.Millisecond)
+ assert.Equal(t, []string{"debug-level\n", "error-level\n"}, w1.GetLogs())
+ assert.Equal(t, []string{}, w2.GetLogs())
+
+ logger.Close()
+
+ // after Close, all logs are flushed
+ assert.Equal(t, []string{"debug-level\n", "error-level\n"}, w1.GetLogs())
+ assert.Equal(t, []string{"error-level\n"}, w2.GetLogs())
+}
+
+func TestLoggerPause(t *testing.T) {
+ logger := NewLoggerWithWriters(context.Background(), "test")
+
+ w1 := newDummyWriter("dummy-1", DEBUG, 0)
+ logger.AddWriters(w1)
+
+ GetManager().PauseAll()
+ time.Sleep(50 * time.Millisecond)
+
+ logger.Info("info-level")
+ time.Sleep(100 * time.Millisecond)
+ assert.Equal(t, []string{}, w1.GetLogs())
+
+ GetManager().ResumeAll()
+
+ time.Sleep(100 * time.Millisecond)
+ assert.Equal(t, []string{"info-level\n"}, w1.GetLogs())
+
+ logger.Close()
+}
+
+type testLogString struct {
+ Field string
+}
+
+func (t testLogString) LogString() string {
+ return "log-string"
+}
+
+func TestLoggerLogString(t *testing.T) {
+ logger := NewLoggerWithWriters(context.Background(), "test")
+
+ w1 := newDummyWriter("dummy-1", DEBUG, 0)
+ w1.Mode.Colorize = true
+ logger.AddWriters(w1)
+
+ logger.Info("%s %s %#v %v", testLogString{}, &testLogString{}, testLogString{Field: "detail"}, NewColoredValue(testLogString{}, FgRed))
+ logger.Close()
+
+ assert.Equal(t, []string{"log-string log-string log.testLogString{Field:\"detail\"} \x1b[31mlog-string\x1b[0m\n"}, w1.GetLogs())
+}
+
+func TestLoggerExpressionFilter(t *testing.T) {
+ logger := NewLoggerWithWriters(context.Background(), "test")
+
+ w1 := newDummyWriter("dummy-1", DEBUG, 0)
+ w1.Mode.Expression = "foo.*"
+ logger.AddWriters(w1)
+
+ logger.Info("foo")
+ logger.Info("bar")
+ logger.Info("foo bar")
+ logger.SendLogEvent(&Event{Level: INFO, Filename: "foo.go", MsgSimpleText: "by filename"})
+ logger.Close()
+
+ assert.Equal(t, []string{"foo\n", "foo bar\n", "by filename\n"}, w1.GetLogs())
+}
diff --git a/modules/log/manager.go b/modules/log/manager.go
new file mode 100644
index 0000000..0417bbe
--- /dev/null
+++ b/modules/log/manager.go
@@ -0,0 +1,142 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+)
+
+const DEFAULT = "default"
+
+// LoggerManager manages loggers and shared event writers
+type LoggerManager struct {
+ ctx context.Context
+ ctxCancel context.CancelFunc
+
+ mu sync.Mutex
+ writers map[string]EventWriter
+ loggers map[string]*LoggerImpl
+ defaultLogger atomic.Pointer[LoggerImpl]
+
+ pauseMu sync.RWMutex
+ pauseChan chan struct{}
+}
+
+// GetLogger returns a logger with the given name. If the logger doesn't exist, a new empty one will be created.
+func (m *LoggerManager) GetLogger(name string) *LoggerImpl {
+ if name == DEFAULT {
+ if logger := m.defaultLogger.Load(); logger != nil {
+ return logger
+ }
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ logger := m.loggers[name]
+ if logger == nil {
+ logger = NewLoggerWithWriters(m.ctx, name)
+ m.loggers[name] = logger
+ if name == DEFAULT {
+ m.defaultLogger.Store(logger)
+ }
+ }
+
+ return logger
+}
+
+// PauseAll pauses all event writers
+func (m *LoggerManager) PauseAll() {
+ m.pauseMu.Lock()
+ m.pauseChan = make(chan struct{})
+ m.pauseMu.Unlock()
+}
+
+// ResumeAll resumes all event writers
+func (m *LoggerManager) ResumeAll() {
+ m.pauseMu.Lock()
+ close(m.pauseChan)
+ m.pauseChan = nil
+ m.pauseMu.Unlock()
+}
+
+// GetPauseChan returns a channel for writer pausing
+func (m *LoggerManager) GetPauseChan() chan struct{} {
+ m.pauseMu.RLock()
+ defer m.pauseMu.RUnlock()
+ return m.pauseChan
+}
+
+// Close closes the logger manager, all loggers and writers will be closed, the messages are flushed.
+func (m *LoggerManager) Close() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ for _, logger := range m.loggers {
+ logger.Close()
+ }
+ m.loggers = map[string]*LoggerImpl{}
+
+ for _, writer := range m.writers {
+ eventWriterStopWait(writer)
+ }
+ m.writers = map[string]EventWriter{}
+
+ m.ctxCancel()
+}
+
+// DumpLoggers returns a map of all loggers and their event writers, for debugging and display purposes.
+func (m *LoggerManager) DumpLoggers() map[string]any {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ dump := map[string]any{}
+ for name, logger := range m.loggers {
+ loggerDump := map[string]any{
+ "IsEnabled": logger.IsEnabled(),
+ "EventWriters": logger.DumpWriters(),
+ }
+ dump[name] = loggerDump
+ }
+ return dump
+}
+
+// NewSharedWriter creates a new shared event writer, it can be used by multiple loggers, and a shared writer won't be closed if a logger is closed.
+func (m *LoggerManager) NewSharedWriter(writerName, writerType string, mode WriterMode) (writer EventWriter, err error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if _, ok := m.writers[writerName]; ok {
+ return nil, fmt.Errorf("log event writer %q has been added before", writerName)
+ }
+
+ if writer, err = NewEventWriter(writerName, writerType, mode); err != nil {
+ return nil, err
+ }
+
+ m.writers[writerName] = writer
+ eventWriterStartGo(m.ctx, writer, true)
+ return writer, nil
+}
+
+func (m *LoggerManager) GetSharedWriter(writerName string) EventWriter {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.writers[writerName]
+}
+
+var loggerManager = NewManager()
+
+func GetManager() *LoggerManager {
+ return loggerManager
+}
+
+func NewManager() *LoggerManager {
+ m := &LoggerManager{writers: map[string]EventWriter{}, loggers: map[string]*LoggerImpl{}}
+ m.ctx, m.ctxCancel = newProcessTypedContext(context.Background(), "LoggerManager")
+ return m
+}
diff --git a/modules/log/manager_test.go b/modules/log/manager_test.go
new file mode 100644
index 0000000..3839080
--- /dev/null
+++ b/modules/log/manager_test.go
@@ -0,0 +1,43 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSharedWorker(t *testing.T) {
+ RegisterEventWriter("dummy", func(writerName string, writerMode WriterMode) EventWriter {
+ return newDummyWriter(writerName, writerMode.Level, 0)
+ })
+
+ m := NewManager()
+ _, err := m.NewSharedWriter("dummy-1", "dummy", WriterMode{Level: DEBUG, Flags: FlagsFromBits(0)})
+ require.NoError(t, err)
+
+ w := m.GetSharedWriter("dummy-1")
+ assert.NotNil(t, w)
+ loggerTest := m.GetLogger("test")
+ loggerTest.AddWriters(w)
+ loggerTest.Info("msg-1")
+ loggerTest.ReplaceAllWriters() // the shared writer is not closed here
+ loggerTest.Info("never seen")
+
+ // the shared writer can still be used later
+ w = m.GetSharedWriter("dummy-1")
+ assert.NotNil(t, w)
+ loggerTest.AddWriters(w)
+ loggerTest.Info("msg-2")
+
+ m.GetLogger("test-another").AddWriters(w)
+ m.GetLogger("test-another").Info("msg-3")
+
+ m.Close()
+
+ logs := w.(*dummyWriter).GetLogs()
+ assert.Equal(t, []string{"msg-1\n", "msg-2\n", "msg-3\n"}, logs)
+}
diff --git a/modules/log/misc.go b/modules/log/misc.go
new file mode 100644
index 0000000..ae4ce04
--- /dev/null
+++ b/modules/log/misc.go
@@ -0,0 +1,78 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "io"
+)
+
+type baseToLogger struct {
+ base BaseLogger
+}
+
+// BaseLoggerToGeneralLogger wraps a BaseLogger (which only has Log() function) to a Logger (which has Info() function)
+func BaseLoggerToGeneralLogger(b BaseLogger) Logger {
+ l := &baseToLogger{base: b}
+ return l
+}
+
+var _ Logger = (*baseToLogger)(nil)
+
+func (s *baseToLogger) Log(skip int, level Level, format string, v ...any) {
+ s.base.Log(skip+1, level, format, v...)
+}
+
+func (s *baseToLogger) GetLevel() Level {
+ return s.base.GetLevel()
+}
+
+func (s *baseToLogger) LevelEnabled(level Level) bool {
+ return s.base.GetLevel() <= level
+}
+
+func (s *baseToLogger) Trace(format string, v ...any) {
+ s.base.Log(1, TRACE, format, v...)
+}
+
+func (s *baseToLogger) Debug(format string, v ...any) {
+ s.base.Log(1, DEBUG, format, v...)
+}
+
+func (s *baseToLogger) Info(format string, v ...any) {
+ s.base.Log(1, INFO, format, v...)
+}
+
+func (s *baseToLogger) Warn(format string, v ...any) {
+ s.base.Log(1, WARN, format, v...)
+}
+
+func (s *baseToLogger) Error(format string, v ...any) {
+ s.base.Log(1, ERROR, format, v...)
+}
+
+func (s *baseToLogger) Critical(format string, v ...any) {
+ s.base.Log(1, CRITICAL, format, v...)
+}
+
+type PrintfLogger struct {
+ Logf func(format string, args ...any)
+}
+
+func (p *PrintfLogger) Printf(format string, args ...any) {
+ p.Logf(format, args...)
+}
+
+type loggerToWriter struct {
+ logf func(format string, args ...any)
+}
+
+func (p *loggerToWriter) Write(bs []byte) (int, error) {
+ p.logf("%s", string(bs))
+ return len(bs), nil
+}
+
+// LoggerToWriter wraps a log function to an io.Writer
+func LoggerToWriter(logf func(format string, args ...any)) io.Writer {
+ return &loggerToWriter{logf: logf}
+}
diff --git a/modules/log/stack.go b/modules/log/stack.go
new file mode 100644
index 0000000..9b22e92
--- /dev/null
+++ b/modules/log/stack.go
@@ -0,0 +1,80 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package log
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "runtime"
+)
+
+var unknown = []byte("???")
+
+// Stack will skip back the provided number of frames and return a stack trace with source code.
+// Although we could just use debug.Stack(), this routine will return the source code and
+// skip back the provided number of frames - i.e. allowing us to ignore preceding function calls.
+// A skip of 0 returns the stack trace for the calling function, not including this call.
+// If the problem is a lack of memory of course all this is not going to work...
+func Stack(skip int) string {
+ buf := new(bytes.Buffer)
+
+ // Store the last file we opened as its probable that the preceding stack frame
+ // will be in the same file
+ var lines [][]byte
+ var lastFilename string
+ for i := skip + 1; ; i++ { // Skip over frames
+ programCounter, filename, lineNumber, ok := runtime.Caller(i)
+ // If we can't retrieve the information break - basically we're into go internals at this point.
+ if !ok {
+ break
+ }
+
+ // Print equivalent of debug.Stack()
+ _, _ = fmt.Fprintf(buf, "%s:%d (0x%x)\n", filename, lineNumber, programCounter)
+ // Now try to print the offending line
+ if filename != lastFilename {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ // can't read this source file
+ // likely we don't have the sourcecode available
+ continue
+ }
+ lines = bytes.Split(data, []byte{'\n'})
+ lastFilename = filename
+ }
+ _, _ = fmt.Fprintf(buf, "\t%s: %s\n", functionName(programCounter), source(lines, lineNumber))
+ }
+ return buf.String()
+}
+
+// functionName converts the provided programCounter into a function name
+func functionName(programCounter uintptr) []byte {
+ function := runtime.FuncForPC(programCounter)
+ if function == nil {
+ return unknown
+ }
+ name := []byte(function.Name())
+
+ // Because we provide the filename we can drop the preceding package name.
+ if lastslash := bytes.LastIndex(name, []byte("/")); lastslash >= 0 {
+ name = name[lastslash+1:]
+ }
+ // And the current package name.
+ if period := bytes.Index(name, []byte(".")); period >= 0 {
+ name = name[period+1:]
+ }
+ // And we should just replace the interpunct with a dot
+ name = bytes.ReplaceAll(name, []byte("·"), []byte("."))
+ return name
+}
+
+// source returns a space-trimmed slice of the n'th line.
+func source(lines [][]byte, n int) []byte {
+ n-- // in stack trace, lines are 1-indexed but our array is 0-indexed
+ if n < 0 || n >= len(lines) {
+ return unknown
+ }
+ return bytes.TrimSpace(lines[n])
+}
diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go
new file mode 100644
index 0000000..0678062
--- /dev/null
+++ b/modules/markup/asciicast/asciicast.go
@@ -0,0 +1,64 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asciicast
+
+import (
+ "fmt"
+ "io"
+ "net/url"
+ "regexp"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func init() {
+ markup.RegisterRenderer(Renderer{})
+}
+
+// Renderer implements markup.Renderer for asciicast files.
+// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
+type Renderer struct{}
+
+// Name implements markup.Renderer
+func (Renderer) Name() string {
+ return "asciicast"
+}
+
+// Extensions implements markup.Renderer
+func (Renderer) Extensions() []string {
+ return []string{".cast"}
+}
+
+const (
+ playerClassName = "asciinema-player-container"
+ playerSrcAttr = "data-asciinema-player-src"
+)
+
+// SanitizerRules implements markup.Renderer
+func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return []setting.MarkupSanitizerRule{
+ {Element: "div", AllowAttr: "class", Regexp: regexp.MustCompile(playerClassName)},
+ {Element: "div", AllowAttr: playerSrcAttr},
+ }
+}
+
+// Render implements markup.Renderer
+func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
+ rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
+ setting.AppSubURL,
+ url.PathEscape(ctx.Metas["user"]),
+ url.PathEscape(ctx.Metas["repo"]),
+ ctx.Metas["BranchNameSubURL"],
+ url.PathEscape(ctx.RelativePath),
+ )
+
+ _, err := io.WriteString(output, fmt.Sprintf(
+ `<div class="%s" %s="%s"></div>`,
+ playerClassName,
+ playerSrcAttr,
+ rawURL,
+ ))
+ return err
+}
diff --git a/modules/markup/camo.go b/modules/markup/camo.go
new file mode 100644
index 0000000..7e25834
--- /dev/null
+++ b/modules/markup/camo.go
@@ -0,0 +1,46 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// CamoEncode encodes a lnk to fit with the go-camo and camo proxy links. The purposes of camo-proxy are:
+// 1. Allow accessing "http://" images on a HTTPS site by using the "https://" URLs provided by camo-proxy.
+// 2. Hide the visitor's real IP (protect privacy) when accessing external images.
+func CamoEncode(link string) string {
+ if strings.HasPrefix(link, setting.Camo.ServerURL) {
+ return link
+ }
+
+ mac := hmac.New(sha1.New, []byte(setting.Camo.HMACKey))
+ _, _ = mac.Write([]byte(link)) // hmac does not return errors
+ macSum := b64encode(mac.Sum(nil))
+ encodedURL := b64encode([]byte(link))
+
+ return util.URLJoin(setting.Camo.ServerURL, macSum, encodedURL)
+}
+
+func b64encode(data []byte) string {
+ return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
+}
+
+func camoHandleLink(link string) string {
+ if setting.Camo.Enabled {
+ lnkURL, err := url.Parse(link)
+ if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) &&
+ (setting.Camo.Always || lnkURL.Scheme != "https") {
+ return CamoEncode(link)
+ }
+ }
+ return link
+}
diff --git a/modules/markup/camo_test.go b/modules/markup/camo_test.go
new file mode 100644
index 0000000..3c5d40a
--- /dev/null
+++ b/modules/markup/camo_test.go
@@ -0,0 +1,44 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCamoHandleLink(t *testing.T) {
+ setting.AppURL = "https://gitea.com"
+ // Test media proxy
+ setting.Camo.Enabled = true
+ setting.Camo.ServerURL = "https://image.proxy"
+ setting.Camo.HMACKey = "geheim"
+
+ assert.Equal(t,
+ "https://gitea.com/img.jpg",
+ camoHandleLink("https://gitea.com/img.jpg"))
+ assert.Equal(t,
+ "https://testimages.org/img.jpg",
+ camoHandleLink("https://testimages.org/img.jpg"))
+ assert.Equal(t,
+ "https://image.proxy/eivin43gJwGVIjR9MiYYtFIk0mw/aHR0cDovL3Rlc3RpbWFnZXMub3JnL2ltZy5qcGc",
+ camoHandleLink("http://testimages.org/img.jpg"))
+
+ setting.Camo.Always = true
+ assert.Equal(t,
+ "https://gitea.com/img.jpg",
+ camoHandleLink("https://gitea.com/img.jpg"))
+ assert.Equal(t,
+ "https://image.proxy/tkdlvmqpbIr7SjONfHNgEU622y0/aHR0cHM6Ly90ZXN0aW1hZ2VzLm9yZy9pbWcuanBn",
+ camoHandleLink("https://testimages.org/img.jpg"))
+ assert.Equal(t,
+ "https://image.proxy/eivin43gJwGVIjR9MiYYtFIk0mw/aHR0cDovL3Rlc3RpbWFnZXMub3JnL2ltZy5qcGc",
+ camoHandleLink("http://testimages.org/img.jpg"))
+
+ // Restore previous settings
+ setting.Camo.Enabled = false
+}
diff --git a/modules/markup/common/footnote.go b/modules/markup/common/footnote.go
new file mode 100644
index 0000000..0e75e2a
--- /dev/null
+++ b/modules/markup/common/footnote.go
@@ -0,0 +1,498 @@
+// Copyright 2019 Yusuke Inuzuka
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
+
+package common
+
+import (
+ "bytes"
+ "fmt"
+ "strconv"
+ "unicode"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+// CleanValue will clean a value to make it safe to be an id
+// This function is quite different from the original goldmark function
+// and more closely matches the output from the shurcooL sanitizer
+// In particular Unicode letters and numbers are a lot more than a-zA-Z0-9...
+func CleanValue(value []byte) []byte {
+ value = bytes.TrimSpace(value)
+ rs := bytes.Runes(value)
+ result := make([]rune, 0, len(rs))
+ needsDash := false
+ for _, r := range rs {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_':
+ if needsDash && len(result) > 0 {
+ result = append(result, '-')
+ }
+ needsDash = false
+ result = append(result, unicode.ToLower(r))
+ default:
+ needsDash = true
+ }
+ }
+ return []byte(string(result))
+}
+
+// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
+
+// A FootnoteLink struct represents a link to a footnote of Markdown
+// (PHP Markdown Extra) text.
+type FootnoteLink struct {
+ ast.BaseInline
+ Index int
+ Name []byte
+}
+
+// Dump implements Node.Dump.
+func (n *FootnoteLink) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Index"] = fmt.Sprintf("%v", n.Index)
+ m["Name"] = fmt.Sprintf("%v", n.Name)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindFootnoteLink is a NodeKind of the FootnoteLink node.
+var KindFootnoteLink = ast.NewNodeKind("GiteaFootnoteLink")
+
+// Kind implements Node.Kind.
+func (n *FootnoteLink) Kind() ast.NodeKind {
+ return KindFootnoteLink
+}
+
+// NewFootnoteLink returns a new FootnoteLink node.
+func NewFootnoteLink(index int, name []byte) *FootnoteLink {
+ return &FootnoteLink{
+ Index: index,
+ Name: name,
+ }
+}
+
+// A FootnoteBackLink struct represents a link to a footnote of Markdown
+// (PHP Markdown Extra) text.
+type FootnoteBackLink struct {
+ ast.BaseInline
+ Index int
+ Name []byte
+}
+
+// Dump implements Node.Dump.
+func (n *FootnoteBackLink) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Index"] = fmt.Sprintf("%v", n.Index)
+ m["Name"] = fmt.Sprintf("%v", n.Name)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindFootnoteBackLink is a NodeKind of the FootnoteBackLink node.
+var KindFootnoteBackLink = ast.NewNodeKind("GiteaFootnoteBackLink")
+
+// Kind implements Node.Kind.
+func (n *FootnoteBackLink) Kind() ast.NodeKind {
+ return KindFootnoteBackLink
+}
+
+// NewFootnoteBackLink returns a new FootnoteBackLink node.
+func NewFootnoteBackLink(index int, name []byte) *FootnoteBackLink {
+ return &FootnoteBackLink{
+ Index: index,
+ Name: name,
+ }
+}
+
+// A Footnote struct represents a footnote of Markdown
+// (PHP Markdown Extra) text.
+type Footnote struct {
+ ast.BaseBlock
+ Ref []byte
+ Index int
+ Name []byte
+}
+
+// Dump implements Node.Dump.
+func (n *Footnote) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Index"] = strconv.Itoa(n.Index)
+ m["Ref"] = string(n.Ref)
+ m["Name"] = string(n.Name)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindFootnote is a NodeKind of the Footnote node.
+var KindFootnote = ast.NewNodeKind("GiteaFootnote")
+
+// Kind implements Node.Kind.
+func (n *Footnote) Kind() ast.NodeKind {
+ return KindFootnote
+}
+
+// NewFootnote returns a new Footnote node.
+func NewFootnote(ref []byte) *Footnote {
+ return &Footnote{
+ Ref: ref,
+ Index: -1,
+ Name: ref,
+ }
+}
+
+// A FootnoteList struct represents footnotes of Markdown
+// (PHP Markdown Extra) text.
+type FootnoteList struct {
+ ast.BaseBlock
+ Count int
+}
+
+// Dump implements Node.Dump.
+func (n *FootnoteList) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Count"] = fmt.Sprintf("%v", n.Count)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindFootnoteList is a NodeKind of the FootnoteList node.
+var KindFootnoteList = ast.NewNodeKind("GiteaFootnoteList")
+
+// Kind implements Node.Kind.
+func (n *FootnoteList) Kind() ast.NodeKind {
+ return KindFootnoteList
+}
+
+// NewFootnoteList returns a new FootnoteList node.
+func NewFootnoteList() *FootnoteList {
+ return &FootnoteList{
+ Count: 0,
+ }
+}
+
+var footnoteListKey = parser.NewContextKey()
+
+type footnoteBlockParser struct{}
+
+var defaultFootnoteBlockParser = &footnoteBlockParser{}
+
+// NewFootnoteBlockParser returns a new parser.BlockParser that can parse
+// footnotes of the Markdown(PHP Markdown Extra) text.
+func NewFootnoteBlockParser() parser.BlockParser {
+ return defaultFootnoteBlockParser
+}
+
+func (b *footnoteBlockParser) Trigger() []byte {
+ return []byte{'['}
+}
+
+func (b *footnoteBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
+ line, segment := reader.PeekLine()
+ pos := pc.BlockOffset()
+ if pos < 0 || line[pos] != '[' {
+ return nil, parser.NoChildren
+ }
+ pos++
+ if pos > len(line)-1 || line[pos] != '^' {
+ return nil, parser.NoChildren
+ }
+ open := pos + 1
+ closure := util.FindClosure(line[pos+1:], '[', ']', false, false) //nolint
+ closes := pos + 1 + closure
+ next := closes + 1
+ if closure > -1 {
+ if next >= len(line) || line[next] != ':' {
+ return nil, parser.NoChildren
+ }
+ } else {
+ return nil, parser.NoChildren
+ }
+ padding := segment.Padding
+ label := reader.Value(text.NewSegment(segment.Start+open-padding, segment.Start+closes-padding))
+ if util.IsBlank(label) {
+ return nil, parser.NoChildren
+ }
+ item := NewFootnote(label)
+
+ pos = next + 1 - padding
+ if pos >= len(line) {
+ reader.Advance(pos)
+ return item, parser.NoChildren
+ }
+ reader.AdvanceAndSetPadding(pos, padding)
+ return item, parser.HasChildren
+}
+
+func (b *footnoteBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
+ line, _ := reader.PeekLine()
+ if util.IsBlank(line) {
+ return parser.Continue | parser.HasChildren
+ }
+ childpos, padding := util.IndentPosition(line, reader.LineOffset(), 4)
+ if childpos < 0 {
+ return parser.Close
+ }
+ reader.AdvanceAndSetPadding(childpos, padding)
+ return parser.Continue | parser.HasChildren
+}
+
+func (b *footnoteBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
+ var list *FootnoteList
+ if tlist := pc.Get(footnoteListKey); tlist != nil {
+ list = tlist.(*FootnoteList)
+ } else {
+ list = NewFootnoteList()
+ pc.Set(footnoteListKey, list)
+ node.Parent().InsertBefore(node.Parent(), node, list)
+ }
+ node.Parent().RemoveChild(node.Parent(), node)
+ list.AppendChild(list, node)
+}
+
+func (b *footnoteBlockParser) CanInterruptParagraph() bool {
+ return true
+}
+
+func (b *footnoteBlockParser) CanAcceptIndentedLine() bool {
+ return false
+}
+
+type footnoteParser struct{}
+
+var defaultFootnoteParser = &footnoteParser{}
+
+// NewFootnoteParser returns a new parser.InlineParser that can parse
+// footnote links of the Markdown(PHP Markdown Extra) text.
+func NewFootnoteParser() parser.InlineParser {
+ return defaultFootnoteParser
+}
+
+func (s *footnoteParser) Trigger() []byte {
+ // footnote syntax probably conflict with the image syntax.
+ // So we need trigger this parser with '!'.
+ return []byte{'!', '['}
+}
+
+func (s *footnoteParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+ line, segment := block.PeekLine()
+ pos := 1
+ if len(line) > 0 && line[0] == '!' {
+ pos++
+ }
+ if pos >= len(line) || line[pos] != '^' {
+ return nil
+ }
+ pos++
+ if pos >= len(line) {
+ return nil
+ }
+ open := pos
+ closure := util.FindClosure(line[pos:], '[', ']', false, false) //nolint
+ if closure < 0 {
+ return nil
+ }
+ closes := pos + closure
+ value := block.Value(text.NewSegment(segment.Start+open, segment.Start+closes))
+ block.Advance(closes + 1)
+
+ var list *FootnoteList
+ if tlist := pc.Get(footnoteListKey); tlist != nil {
+ list = tlist.(*FootnoteList)
+ }
+ if list == nil {
+ return nil
+ }
+ index := 0
+ name := []byte{}
+ for def := list.FirstChild(); def != nil; def = def.NextSibling() {
+ d := def.(*Footnote)
+ if bytes.Equal(d.Ref, value) {
+ if d.Index < 0 {
+ list.Count++
+ d.Index = list.Count
+ val := CleanValue(d.Name)
+ if len(val) == 0 {
+ val = []byte(strconv.Itoa(d.Index))
+ }
+ d.Name = pc.IDs().Generate(val, KindFootnote)
+ }
+ index = d.Index
+ name = d.Name
+ break
+ }
+ }
+ if index == 0 {
+ return nil
+ }
+
+ return NewFootnoteLink(index, name)
+}
+
+type footnoteASTTransformer struct{}
+
+var defaultFootnoteASTTransformer = &footnoteASTTransformer{}
+
+// NewFootnoteASTTransformer returns a new parser.ASTTransformer that
+// insert a footnote list to the last of the document.
+func NewFootnoteASTTransformer() parser.ASTTransformer {
+ return defaultFootnoteASTTransformer
+}
+
+func (a *footnoteASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
+ var list *FootnoteList
+ if tlist := pc.Get(footnoteListKey); tlist != nil {
+ list = tlist.(*FootnoteList)
+ } else {
+ return
+ }
+ pc.Set(footnoteListKey, nil)
+ for footnote := list.FirstChild(); footnote != nil; {
+ container := footnote
+ next := footnote.NextSibling()
+ if fc := container.LastChild(); fc != nil && ast.IsParagraph(fc) {
+ container = fc
+ }
+ footnoteNode := footnote.(*Footnote)
+ index := footnoteNode.Index
+ name := footnoteNode.Name
+ if index < 0 {
+ list.RemoveChild(list, footnote)
+ } else {
+ container.AppendChild(container, NewFootnoteBackLink(index, name))
+ }
+ footnote = next
+ }
+ list.SortChildren(func(n1, n2 ast.Node) int {
+ if n1.(*Footnote).Index < n2.(*Footnote).Index {
+ return -1
+ }
+ return 1
+ })
+ if list.Count <= 0 {
+ list.Parent().RemoveChild(list.Parent(), list)
+ return
+ }
+
+ node.AppendChild(node, list)
+}
+
+// FootnoteHTMLRenderer is a renderer.NodeRenderer implementation that
+// renders FootnoteLink nodes.
+type FootnoteHTMLRenderer struct {
+ html.Config
+}
+
+// NewFootnoteHTMLRenderer returns a new FootnoteHTMLRenderer.
+func NewFootnoteHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &FootnoteHTMLRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
+
+// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+func (r *FootnoteHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindFootnoteLink, r.renderFootnoteLink)
+ reg.Register(KindFootnoteBackLink, r.renderFootnoteBackLink)
+ reg.Register(KindFootnote, r.renderFootnote)
+ reg.Register(KindFootnoteList, r.renderFootnoteList)
+}
+
+func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ n := node.(*FootnoteLink)
+ is := strconv.Itoa(n.Index)
+ _, _ = w.WriteString(`<sup id="fnref:`)
+ _, _ = w.Write(n.Name)
+ _, _ = w.WriteString(`"><a href="#fn:`)
+ _, _ = w.Write(n.Name)
+ _, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`)
+ _, _ = w.WriteString(is)
+ _, _ = w.WriteString(`</a></sup>`)
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ n := node.(*FootnoteBackLink)
+ _, _ = w.WriteString(` <a href="#fnref:`)
+ _, _ = w.Write(n.Name)
+ _, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
+ _, _ = w.WriteString("&#x21a9;&#xfe0e;")
+ _, _ = w.WriteString(`</a>`)
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*Footnote)
+ if entering {
+ _, _ = w.WriteString(`<li id="fn:`)
+ _, _ = w.Write(n.Name)
+ _, _ = w.WriteString(`" role="doc-endnote"`)
+ if node.Attributes() != nil {
+ html.RenderAttributes(w, node, html.ListItemAttributeFilter)
+ }
+ _, _ = w.WriteString(">\n")
+ } else {
+ _, _ = w.WriteString("</li>\n")
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *FootnoteHTMLRenderer) renderFootnoteList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ tag := "div"
+ if entering {
+ _, _ = w.WriteString("<")
+ _, _ = w.WriteString(tag)
+ _, _ = w.WriteString(` class="footnotes" role="doc-endnotes"`)
+ if node.Attributes() != nil {
+ html.RenderAttributes(w, node, html.GlobalAttributeFilter)
+ }
+ _ = w.WriteByte('>')
+ if r.Config.XHTML {
+ _, _ = w.WriteString("\n<hr />\n")
+ } else {
+ _, _ = w.WriteString("\n<hr>\n")
+ }
+ _, _ = w.WriteString("<ol>\n")
+ } else {
+ _, _ = w.WriteString("</ol>\n")
+ _, _ = w.WriteString("</")
+ _, _ = w.WriteString(tag)
+ _, _ = w.WriteString(">\n")
+ }
+ return ast.WalkContinue, nil
+}
+
+type footnoteExtension struct{}
+
+// FootnoteExtension represents the Gitea Footnote
+var FootnoteExtension = &footnoteExtension{}
+
+// Extend extends the markdown converter with the Gitea Footnote parser
+func (e *footnoteExtension) Extend(m goldmark.Markdown) {
+ m.Parser().AddOptions(
+ parser.WithBlockParsers(
+ util.Prioritized(NewFootnoteBlockParser(), 999),
+ ),
+ parser.WithInlineParsers(
+ util.Prioritized(NewFootnoteParser(), 101),
+ ),
+ parser.WithASTTransformers(
+ util.Prioritized(NewFootnoteASTTransformer(), 999),
+ ),
+ )
+ m.Renderer().AddOptions(renderer.WithNodeRenderers(
+ util.Prioritized(NewFootnoteHTMLRenderer(), 500),
+ ))
+}
diff --git a/modules/markup/common/footnote_test.go b/modules/markup/common/footnote_test.go
new file mode 100644
index 0000000..62763c5
--- /dev/null
+++ b/modules/markup/common/footnote_test.go
@@ -0,0 +1,62 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package common
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCleanValue(t *testing.T) {
+ tests := []struct {
+ param string
+ expect string
+ }{
+ // Github behavior test cases
+ {"", ""},
+ {"test.0.1", "test-0-1"},
+ {"test(0)", "test-0"},
+ {"test!1", "test-1"},
+ {"test:2", "test-2"},
+ {"test*3", "test-3"},
+ {"testï¼4", "test-4"},
+ {"test:5", "test-5"},
+ {"test*6", "test-6"},
+ {"test:6 a", "test-6-a"},
+ {"test:6 !b", "test-6-b"},
+ {"test:ad # df", "test-ad-df"},
+ {"test:ad #23 df 2*/*", "test-ad-23-df-2"},
+ {"test:ad 23 df 2*/*", "test-ad-23-df-2"},
+ {"test:ad # 23 df 2*/*", "test-ad-23-df-2"},
+ {"Anchors in Markdown", "anchors-in-markdown"},
+ {"a_b_c", "a_b_c"},
+ {"a-b-c", "a-b-c"},
+ {"a-b-c----", "a-b-c"},
+ {"test:6a", "test-6a"},
+ {"test:a6", "test-a6"},
+ {"tes a a a a", "tes-a-a-a-a"},
+ {" tes a a a a ", "tes-a-a-a-a"},
+ {"Header with \"double quotes\"", "header-with-double-quotes"},
+ {"Placeholder to force scrolling on link's click", "placeholder-to-force-scrolling-on-link-s-click"},
+ {"tes()", "tes"},
+ {"tes(0)", "tes-0"},
+ {"tes{0}", "tes-0"},
+ {"tes[0]", "tes-0"},
+ {"testã€0】", "test-0"},
+ {"tes…@a", "tes-a"},
+ {"tesï¿¥& a", "tes-a"},
+ {"tes= a", "tes-a"},
+ {"tes|a", "tes-a"},
+ {"tes\\a", "tes-a"},
+ {"tes/a", "tes-a"},
+ {"aå•Šå•Šb", "aå•Šå•Šb"},
+ {"c🤔ï¸ðŸ¤”ï¸d", "c-d"},
+ {"aâš¡a", "a-a"},
+ {"e.~f", "e-f"},
+ }
+ for _, test := range tests {
+ assert.Equal(t, []byte(test.expect), CleanValue([]byte(test.param)), test.param)
+ }
+}
diff --git a/modules/markup/common/html.go b/modules/markup/common/html.go
new file mode 100644
index 0000000..5658839
--- /dev/null
+++ b/modules/markup/common/html.go
@@ -0,0 +1,16 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "mvdan.cc/xurls/v2"
+)
+
+// NOTE: All below regex matching do not perform any extra validation.
+// Thus a link is produced even if the linked entity does not exist.
+// While fast, this is also incorrect and lead to false positives.
+// TODO: fix invalid linking issue
+
+// LinkRegex is a regexp matching a valid link
+var LinkRegex, _ = xurls.StrictMatchingScheme("https?://")
diff --git a/modules/markup/common/linkify.go b/modules/markup/common/linkify.go
new file mode 100644
index 0000000..f846802
--- /dev/null
+++ b/modules/markup/common/linkify.go
@@ -0,0 +1,153 @@
+// Copyright 2019 Yusuke Inuzuka
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Most of this file is a subtly changed version of github.com/yuin/goldmark/extension/linkify.go
+
+package common
+
+import (
+ "bytes"
+ "regexp"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+var wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`)
+
+type linkifyParser struct{}
+
+var defaultLinkifyParser = &linkifyParser{}
+
+// NewLinkifyParser return a new InlineParser can parse
+// text that seems like a URL.
+func NewLinkifyParser() parser.InlineParser {
+ return defaultLinkifyParser
+}
+
+func (s *linkifyParser) Trigger() []byte {
+ // ' ' indicates any white spaces and a line head
+ return []byte{' ', '*', '_', '~', '('}
+}
+
+var (
+ protoHTTP = []byte("http:")
+ protoHTTPS = []byte("https:")
+ protoFTP = []byte("ftp:")
+ domainWWW = []byte("www.")
+)
+
+func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+ if pc.IsInLinkLabel() {
+ return nil
+ }
+ line, segment := block.PeekLine()
+ consumes := 0
+ start := segment.Start
+ c := line[0]
+ // advance if current position is not a line head.
+ if c == ' ' || c == '*' || c == '_' || c == '~' || c == '(' {
+ consumes++
+ start++
+ line = line[1:]
+ }
+
+ var m []int
+ var protocol []byte
+ typ := ast.AutoLinkURL
+ if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) {
+ m = LinkRegex.FindSubmatchIndex(line)
+ }
+ if m == nil && bytes.HasPrefix(line, domainWWW) {
+ m = wwwURLRegxp.FindSubmatchIndex(line)
+ protocol = []byte("http")
+ }
+ if m != nil {
+ lastChar := line[m[1]-1]
+ if lastChar == '.' {
+ m[1]--
+ } else if lastChar == ')' {
+ closing := 0
+ for i := m[1] - 1; i >= m[0]; i-- {
+ if line[i] == ')' {
+ closing++
+ } else if line[i] == '(' {
+ closing--
+ }
+ }
+ if closing > 0 {
+ m[1] -= closing
+ }
+ } else if lastChar == ';' {
+ i := m[1] - 2
+ for ; i >= m[0]; i-- {
+ if util.IsAlphaNumeric(line[i]) {
+ continue
+ }
+ break
+ }
+ if i != m[1]-2 {
+ if line[i] == '&' {
+ m[1] -= m[1] - i
+ }
+ }
+ }
+ }
+ if m == nil {
+ if len(line) > 0 && util.IsPunct(line[0]) {
+ return nil
+ }
+ typ = ast.AutoLinkEmail
+ stop := util.FindEmailIndex(line)
+ if stop < 0 {
+ return nil
+ }
+ at := bytes.IndexByte(line, '@')
+ m = []int{0, stop, at, stop - 1}
+ if bytes.IndexByte(line[m[2]:m[3]], '.') < 0 {
+ return nil
+ }
+ lastChar := line[m[1]-1]
+ if lastChar == '.' {
+ m[1]--
+ }
+ if m[1] < len(line) {
+ nextChar := line[m[1]]
+ if nextChar == '-' || nextChar == '_' {
+ return nil
+ }
+ }
+ }
+
+ if consumes != 0 {
+ s := segment.WithStop(segment.Start + 1)
+ ast.MergeOrAppendTextSegment(parent, s)
+ }
+ consumes += m[1]
+ block.Advance(consumes)
+ n := ast.NewTextSegment(text.NewSegment(start, start+m[1]))
+ link := ast.NewAutoLink(typ, n)
+ link.Protocol = protocol
+ return link
+}
+
+func (s *linkifyParser) CloseBlock(parent ast.Node, pc parser.Context) {
+ // nothing to do
+}
+
+type linkify struct{}
+
+// Linkify is an extension that allow you to parse text that seems like a URL.
+var Linkify = &linkify{}
+
+func (e *linkify) Extend(m goldmark.Markdown) {
+ m.Parser().AddOptions(
+ parser.WithInlineParsers(
+ util.Prioritized(NewLinkifyParser(), 999),
+ ),
+ )
+}
diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go
new file mode 100644
index 0000000..f544ab2
--- /dev/null
+++ b/modules/markup/console/console.go
@@ -0,0 +1,89 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package console
+
+import (
+ "bytes"
+ "io"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+
+ trend "github.com/buildkite/terminal-to-html/v3"
+ "github.com/go-enry/go-enry/v2"
+)
+
+// MarkupName describes markup's name
+var MarkupName = "console"
+
+func init() {
+ markup.RegisterRenderer(Renderer{})
+}
+
+// Renderer implements markup.Renderer
+type Renderer struct{}
+
+// Name implements markup.Renderer
+func (Renderer) Name() string {
+ return MarkupName
+}
+
+// Extensions implements markup.Renderer
+func (Renderer) Extensions() []string {
+ return []string{".sh-session"}
+}
+
+// SanitizerRules implements markup.Renderer
+func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return []setting.MarkupSanitizerRule{
+ {Element: "span", AllowAttr: "class", Regexp: regexp.MustCompile(`^term-((fg[ix]?|bg)\d+|container)$`)},
+ }
+}
+
+// CanRender implements markup.RendererContentDetector
+func (Renderer) CanRender(filename string, input io.Reader) bool {
+ buf, err := io.ReadAll(input)
+ if err != nil {
+ return false
+ }
+ if enry.GetLanguage(filepath.Base(filename), buf) != enry.OtherLanguage {
+ return false
+ }
+ return bytes.ContainsRune(buf, '\x1b')
+}
+
+// Render renders terminal colors to HTML with all specific handling stuff.
+func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ screen, err := trend.NewScreen()
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(screen, input); err != nil {
+ return err
+ }
+ buf := screen.AsHTML()
+ buf = strings.ReplaceAll(buf, "\n", `<br>`)
+ _, err = output.Write([]byte(buf))
+ return err
+}
+
+// Render renders terminal colors to HTML with all specific handling stuff.
+func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ if ctx.Type == "" {
+ ctx.Type = MarkupName
+ }
+ return markup.Render(ctx, input, output)
+}
+
+// RenderString renders terminal colors in string to HTML with all specific handling stuff and return string
+func RenderString(ctx *markup.RenderContext, content string) (string, error) {
+ var buf strings.Builder
+ if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/modules/markup/console/console_test.go b/modules/markup/console/console_test.go
new file mode 100644
index 0000000..0d4a2bb
--- /dev/null
+++ b/modules/markup/console/console_test.go
@@ -0,0 +1,33 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package console
+
+import (
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRenderConsole(t *testing.T) {
+ var render Renderer
+ kases := map[string]string{
+ "\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok": "<span class=\"term-fg37 term-bg40\">npm</span> <span class=\"term-fg32\">info</span> <span class=\"term-fg35\">it worked if it ends with</span> ok",
+ }
+
+ for k, v := range kases {
+ var buf strings.Builder
+ canRender := render.CanRender("test", strings.NewReader(k))
+ assert.True(t, canRender)
+
+ err := render.Render(&markup.RenderContext{Ctx: git.DefaultContext},
+ strings.NewReader(k), &buf)
+ require.NoError(t, err)
+ assert.EqualValues(t, v, buf.String())
+ }
+}
diff --git a/modules/markup/csv/csv.go b/modules/markup/csv/csv.go
new file mode 100644
index 0000000..3d952b0
--- /dev/null
+++ b/modules/markup/csv/csv.go
@@ -0,0 +1,157 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "bufio"
+ "html"
+ "io"
+ "regexp"
+ "strconv"
+
+ "code.gitea.io/gitea/modules/csv"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+)
+
+func init() {
+ markup.RegisterRenderer(Renderer{})
+}
+
+// Renderer implements markup.Renderer for csv files
+type Renderer struct{}
+
+// Name implements markup.Renderer
+func (Renderer) Name() string {
+ return "csv"
+}
+
+// Extensions implements markup.Renderer
+func (Renderer) Extensions() []string {
+ return []string{".csv", ".tsv"}
+}
+
+// SanitizerRules implements markup.Renderer
+func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return []setting.MarkupSanitizerRule{
+ {Element: "table", AllowAttr: "class", Regexp: regexp.MustCompile(`data-table`)},
+ {Element: "th", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)},
+ {Element: "td", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)},
+ }
+}
+
+func writeField(w io.Writer, element, class, field string) error {
+ if _, err := io.WriteString(w, "<"); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, element); err != nil {
+ return err
+ }
+ if len(class) > 0 {
+ if _, err := io.WriteString(w, " class=\""); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, class); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, "\""); err != nil {
+ return err
+ }
+ }
+ if _, err := io.WriteString(w, ">"); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, html.EscapeString(field)); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, "</"); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(w, element); err != nil {
+ return err
+ }
+ _, err := io.WriteString(w, ">")
+ return err
+}
+
+// Render implements markup.Renderer
+func (r Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ tmpBlock := bufio.NewWriter(output)
+ maxSize := setting.UI.CSV.MaxFileSize
+ maxRows := setting.UI.CSV.MaxRows
+
+ if maxSize != 0 {
+ input = io.LimitReader(input, maxSize+1)
+ }
+
+ rd, err := csv.CreateReaderAndDetermineDelimiter(ctx, input)
+ if err != nil {
+ return err
+ }
+ if _, err := tmpBlock.WriteString(`<table class="data-table">`); err != nil {
+ return err
+ }
+
+ row := 0
+ for {
+ fields, err := rd.Read()
+ if err == io.EOF || (row >= maxRows && maxRows != 0) {
+ break
+ }
+ if err != nil {
+ continue
+ }
+
+ if _, err := tmpBlock.WriteString("<tr>"); err != nil {
+ return err
+ }
+ element := "td"
+ if row == 0 {
+ element = "th"
+ }
+ if err := writeField(tmpBlock, element, "line-num", strconv.Itoa(row+1)); err != nil {
+ return err
+ }
+ for _, field := range fields {
+ if err := writeField(tmpBlock, element, "", field); err != nil {
+ return err
+ }
+ }
+ if _, err := tmpBlock.WriteString("</tr>"); err != nil {
+ return err
+ }
+
+ row++
+ }
+
+ if _, err = tmpBlock.WriteString("</table>"); err != nil {
+ return err
+ }
+
+ // Check if maxRows or maxSize is reached, and if true, warn.
+ if (row >= maxRows && maxRows != 0) || (rd.InputOffset() >= maxSize && maxSize != 0) {
+ warn := `<table class="data-table"><tr><td>`
+ rawLink := ` <a href="` + ctx.Links.RawLink() + `/` + util.PathEscapeSegments(ctx.RelativePath) + `">`
+
+ // Try to get the user translation
+ if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
+ warn += locale.TrString("repo.file_too_large")
+ rawLink += locale.TrString("repo.file_view_raw")
+ } else {
+ warn += "The file is too large to be shown."
+ rawLink += "View Raw"
+ }
+
+ warn += rawLink + `</a></td></tr></table>`
+
+ // Write the HTML string to the output
+ if _, err := tmpBlock.WriteString(warn); err != nil {
+ return err
+ }
+ }
+
+ return tmpBlock.Flush()
+}
diff --git a/modules/markup/csv/csv_test.go b/modules/markup/csv/csv_test.go
new file mode 100644
index 0000000..383f134
--- /dev/null
+++ b/modules/markup/csv/csv_test.go
@@ -0,0 +1,33 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRenderCSV(t *testing.T) {
+ var render Renderer
+ kases := map[string]string{
+ "a": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>a</th></tr></table>",
+ "1,2": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>1</th><th>2</th></tr></table>",
+ "1;2\n3;4": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>1</th><th>2</th></tr><tr><td class=\"line-num\">2</td><td>3</td><td>4</td></tr></table>",
+ "<br/>": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>&lt;br/&gt;</th></tr></table>",
+ }
+
+ for k, v := range kases {
+ var buf strings.Builder
+ err := render.Render(&markup.RenderContext{Ctx: git.DefaultContext},
+ strings.NewReader(k), &buf)
+ require.NoError(t, err)
+ assert.EqualValues(t, v, buf.String())
+ }
+}
diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go
new file mode 100644
index 0000000..122517e
--- /dev/null
+++ b/modules/markup/external/external.go
@@ -0,0 +1,146 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package external
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// RegisterRenderers registers all supported third part renderers according settings
+func RegisterRenderers() {
+ for _, renderer := range setting.ExternalMarkupRenderers {
+ if renderer.Enabled && renderer.Command != "" && len(renderer.FileExtensions) > 0 {
+ markup.RegisterRenderer(&Renderer{renderer})
+ }
+ }
+}
+
+// Renderer implements markup.Renderer for external tools
+type Renderer struct {
+ *setting.MarkupRenderer
+}
+
+var (
+ _ markup.PostProcessRenderer = (*Renderer)(nil)
+ _ markup.ExternalRenderer = (*Renderer)(nil)
+)
+
+// Name returns the external tool name
+func (p *Renderer) Name() string {
+ return p.MarkupName
+}
+
+// NeedPostProcess implements markup.Renderer
+func (p *Renderer) NeedPostProcess() bool {
+ return p.MarkupRenderer.NeedPostProcess
+}
+
+// Extensions returns the supported extensions of the tool
+func (p *Renderer) Extensions() []string {
+ return p.FileExtensions
+}
+
+// SanitizerRules implements markup.Renderer
+func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return p.MarkupSanitizerRules
+}
+
+// SanitizerDisabled disabled sanitize if return true
+func (p *Renderer) SanitizerDisabled() bool {
+ return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
+}
+
+// DisplayInIFrame represents whether render the content with an iframe
+func (p *Renderer) DisplayInIFrame() bool {
+ return p.RenderContentMode == setting.RenderContentModeIframe
+}
+
+func envMark(envName string) string {
+ if runtime.GOOS == "windows" {
+ return "%" + envName + "%"
+ }
+ return "$" + envName
+}
+
+// Render renders the data of the document to HTML via the external tool.
+func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ var (
+ command = strings.NewReplacer(
+ envMark("GITEA_PREFIX_SRC"), ctx.Links.SrcLink(),
+ envMark("GITEA_PREFIX_RAW"), ctx.Links.RawLink(),
+ ).Replace(p.Command)
+ commands = strings.Fields(command)
+ args = commands[1:]
+ )
+
+ if p.IsInputFile {
+ // write to temp file
+ f, err := os.CreateTemp("", "gitea_input")
+ if err != nil {
+ return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
+ }
+ tmpPath := f.Name()
+ defer func() {
+ if err := util.Remove(tmpPath); err != nil {
+ log.Warn("Unable to remove temporary file: %s: Error: %v", tmpPath, err)
+ }
+ }()
+
+ _, err = io.Copy(f, input)
+ if err != nil {
+ f.Close()
+ return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
+ }
+
+ err = f.Close()
+ if err != nil {
+ return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
+ }
+ args = append(args, f.Name())
+ }
+
+ if ctx == nil || ctx.Ctx == nil {
+ if ctx == nil {
+ log.Warn("RenderContext not provided defaulting to empty ctx")
+ ctx = &markup.RenderContext{}
+ }
+ log.Warn("RenderContext did not provide context, defaulting to Shutdown context")
+ ctx.Ctx = graceful.GetManager().ShutdownContext()
+ }
+
+ processCtx, _, finished := process.GetManager().AddContext(ctx.Ctx, fmt.Sprintf("Render [%s] for %s", commands[0], ctx.Links.SrcLink()))
+ defer finished()
+
+ cmd := exec.CommandContext(processCtx, commands[0], args...)
+ cmd.Env = append(
+ os.Environ(),
+ "GITEA_PREFIX_SRC="+ctx.Links.SrcLink(),
+ "GITEA_PREFIX_RAW="+ctx.Links.RawLink(),
+ )
+ if !p.IsInputFile {
+ cmd.Stdin = input
+ }
+ var stderr bytes.Buffer
+ cmd.Stdout = output
+ cmd.Stderr = &stderr
+ process.SetSysProcAttribute(cmd)
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
+ }
+ return nil
+}
diff --git a/modules/markup/file_preview.go b/modules/markup/file_preview.go
new file mode 100644
index 0000000..49a5f1e
--- /dev/null
+++ b/modules/markup/file_preview.go
@@ -0,0 +1,364 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "bufio"
+ "bytes"
+ "html/template"
+ "io"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/highlight"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+)
+
+// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
+var filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`)
+
+type FilePreview struct {
+ fileContent []template.HTML
+ title template.HTML
+ subTitle template.HTML
+ lineOffset int
+ start int
+ end int
+ isTruncated bool
+}
+
+func NewFilePreviews(ctx *RenderContext, node *html.Node, locale translation.Locale) []*FilePreview {
+ if setting.FilePreviewMaxLines == 0 {
+ // Feature is disabled
+ return nil
+ }
+
+ mAll := filePreviewPattern.FindAllStringSubmatchIndex(node.Data, -1)
+ if mAll == nil {
+ return nil
+ }
+
+ result := make([]*FilePreview, 0)
+
+ for _, m := range mAll {
+ if slices.Contains(m, -1) {
+ continue
+ }
+
+ preview := newFilePreview(ctx, node, locale, m)
+ if preview != nil {
+ result = append(result, preview)
+ }
+ }
+
+ return result
+}
+
+func newFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale, m []int) *FilePreview {
+ preview := &FilePreview{}
+
+ urlFull := node.Data[m[0]:m[1]]
+
+ // Ensure that we only use links to local repositories
+ if !strings.HasPrefix(urlFull, setting.AppURL) {
+ return nil
+ }
+
+ projPath := strings.TrimPrefix(strings.TrimSuffix(node.Data[m[0]:m[3]], "/"), setting.AppURL)
+
+ commitSha := node.Data[m[4]:m[5]]
+ filePath := node.Data[m[6]:m[7]]
+ hash := node.Data[m[8]:m[9]]
+
+ preview.start = m[0]
+ preview.end = m[1]
+
+ projPathSegments := strings.Split(projPath, "/")
+ if len(projPathSegments) != 2 {
+ return nil
+ }
+
+ ownerName := projPathSegments[len(projPathSegments)-2]
+ repoName := projPathSegments[len(projPathSegments)-1]
+
+ var language string
+ fileBlob, err := DefaultProcessorHelper.GetRepoFileBlob(
+ ctx.Ctx,
+ ownerName,
+ repoName,
+ commitSha, filePath,
+ &language,
+ )
+ if err != nil {
+ return nil
+ }
+
+ titleBuffer := new(bytes.Buffer)
+
+ isExternRef := ownerName != ctx.Metas["user"] || repoName != ctx.Metas["repo"]
+ if isExternRef {
+ err = html.Render(titleBuffer, createLink(node.Data[m[0]:m[3]], ownerName+"/"+repoName, ""))
+ if err != nil {
+ log.Error("failed to render repoLink: %v", err)
+ }
+ titleBuffer.WriteString(" &ndash; ")
+ }
+
+ err = html.Render(titleBuffer, createLink(urlFull, filePath, "muted"))
+ if err != nil {
+ log.Error("failed to render filepathLink: %v", err)
+ }
+
+ preview.title = template.HTML(titleBuffer.String())
+
+ lineSpecs := strings.Split(hash, "-")
+
+ commitLinkBuffer := new(bytes.Buffer)
+ commitLinkText := commitSha[0:7]
+ if isExternRef {
+ commitLinkText = ownerName + "/" + repoName + "@" + commitLinkText
+ }
+
+ err = html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitLinkText, "text black"))
+ if err != nil {
+ log.Error("failed to render commitLink: %v", err)
+ }
+
+ var startLine, endLine int
+
+ if len(lineSpecs) == 1 {
+ startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+ endLine = startLine
+ preview.subTitle = locale.Tr(
+ "markup.filepreview.line", startLine,
+ template.HTML(commitLinkBuffer.String()),
+ )
+
+ preview.lineOffset = startLine - 1
+ } else {
+ startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+ endLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
+ preview.subTitle = locale.Tr(
+ "markup.filepreview.lines", startLine, endLine,
+ template.HTML(commitLinkBuffer.String()),
+ )
+
+ preview.lineOffset = startLine - 1
+ }
+
+ lineCount := endLine - (startLine - 1)
+ if startLine < 1 || endLine < 1 || lineCount < 1 {
+ return nil
+ }
+
+ if setting.FilePreviewMaxLines > 0 && lineCount > setting.FilePreviewMaxLines {
+ preview.isTruncated = true
+ lineCount = setting.FilePreviewMaxLines
+ }
+
+ dataRc, err := fileBlob.DataAsync()
+ if err != nil {
+ return nil
+ }
+ defer dataRc.Close()
+
+ reader := bufio.NewReader(dataRc)
+
+ // skip all lines until we find our startLine
+ for i := 1; i < startLine; i++ {
+ _, err := reader.ReadBytes('\n')
+ if err != nil {
+ return nil
+ }
+ }
+
+ // capture the lines we're interested in
+ lineBuffer := new(bytes.Buffer)
+ for i := 0; i < lineCount; i++ {
+ buf, err := reader.ReadBytes('\n')
+ if err == nil || err == io.EOF {
+ lineBuffer.Write(buf)
+ }
+ if err != nil {
+ break
+ }
+ }
+
+ // highlight the file...
+ fileContent, _, err := highlight.File(fileBlob.Name(), language, lineBuffer.Bytes())
+ if err != nil {
+ log.Error("highlight.File failed, fallback to plain text: %v", err)
+ fileContent = highlight.PlainText(lineBuffer.Bytes())
+ }
+ preview.fileContent = fileContent
+
+ return preview
+}
+
+func (p *FilePreview) CreateHTML(locale translation.Locale) *html.Node {
+ table := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Table.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
+ }
+ tbody := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Tbody.String(),
+ }
+
+ status := &charset.EscapeStatus{}
+ statuses := make([]*charset.EscapeStatus, len(p.fileContent))
+ for i, line := range p.fileContent {
+ statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
+ status = status.Or(statuses[i])
+ }
+
+ for idx, code := range p.fileContent {
+ tr := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Tr.String(),
+ }
+
+ lineNum := strconv.Itoa(p.lineOffset + idx + 1)
+
+ tdLinesnum := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Td.String(),
+ Attr: []html.Attribute{
+ {Key: "class", Val: "lines-num"},
+ },
+ }
+ spanLinesNum := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Span.String(),
+ Attr: []html.Attribute{
+ {Key: "data-line-number", Val: lineNum},
+ },
+ }
+ tdLinesnum.AppendChild(spanLinesNum)
+ tr.AppendChild(tdLinesnum)
+
+ if status.Escaped {
+ tdLinesEscape := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Td.String(),
+ Attr: []html.Attribute{
+ {Key: "class", Val: "lines-escape"},
+ },
+ }
+
+ if statuses[idx].Escaped {
+ btnTitle := ""
+ if statuses[idx].HasInvisible {
+ btnTitle += locale.TrString("repo.invisible_runes_line") + " "
+ }
+ if statuses[idx].HasAmbiguous {
+ btnTitle += locale.TrString("repo.ambiguous_runes_line")
+ }
+
+ escapeBtn := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Button.String(),
+ Attr: []html.Attribute{
+ {Key: "class", Val: "toggle-escape-button btn interact-bg"},
+ {Key: "title", Val: btnTitle},
+ },
+ }
+ tdLinesEscape.AppendChild(escapeBtn)
+ }
+
+ tr.AppendChild(tdLinesEscape)
+ }
+
+ tdCode := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Td.String(),
+ Attr: []html.Attribute{
+ {Key: "class", Val: "lines-code chroma"},
+ },
+ }
+ codeInner := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Code.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
+ }
+ codeText := &html.Node{
+ Type: html.RawNode,
+ Data: string(code),
+ }
+ codeInner.AppendChild(codeText)
+ tdCode.AppendChild(codeInner)
+ tr.AppendChild(tdCode)
+
+ tbody.AppendChild(tr)
+ }
+
+ table.AppendChild(tbody)
+
+ twrapper := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Div.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
+ }
+ twrapper.AppendChild(table)
+
+ header := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Div.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "header"}},
+ }
+
+ ptitle := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Div.String(),
+ }
+ ptitle.AppendChild(&html.Node{
+ Type: html.RawNode,
+ Data: string(p.title),
+ })
+ header.AppendChild(ptitle)
+
+ psubtitle := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Span.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "text small grey"}},
+ }
+ psubtitle.AppendChild(&html.Node{
+ Type: html.RawNode,
+ Data: string(p.subTitle),
+ })
+ header.AppendChild(psubtitle)
+
+ node := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Div.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
+ }
+ node.AppendChild(header)
+
+ if p.isTruncated {
+ warning := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Div.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "ui warning message tw-text-left"}},
+ }
+ warning.AppendChild(&html.Node{
+ Type: html.TextNode,
+ Data: locale.TrString("markup.filepreview.truncated"),
+ })
+ node.AppendChild(warning)
+ }
+
+ node.AppendChild(twrapper)
+
+ return node
+}
diff --git a/modules/markup/html.go b/modules/markup/html.go
new file mode 100644
index 0000000..2e65827
--- /dev/null
+++ b/modules/markup/html.go
@@ -0,0 +1,1325 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "bytes"
+ "io"
+ "net/url"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup/common"
+ "code.gitea.io/gitea/modules/references"
+ "code.gitea.io/gitea/modules/regexplru"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates/vars"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+ "mvdan.cc/xurls/v2"
+)
+
+// Issue name styles
+const (
+ IssueNameStyleNumeric = "numeric"
+ IssueNameStyleAlphanumeric = "alphanumeric"
+ IssueNameStyleRegexp = "regexp"
+)
+
+var (
+ // NOTE: All below regex matching do not perform any extra validation.
+ // Thus a link is produced even if the linked entity does not exist.
+ // While fast, this is also incorrect and lead to false positives.
+ // TODO: fix invalid linking issue
+
+ // valid chars in encoded path and parameter: [-+~_%.a-zA-Z0-9/]
+
+ // hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae
+ // Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length
+ // so that abbreviated hash links can be used as well. This matches git and GitHub usability.
+ hashCurrentPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-f]{7,64})(?:\s|$|\)|\]|[.,:](\s|$))`)
+
+ // shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax
+ shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`)
+
+ // anySHA1Pattern splits url containing SHA into parts
+ anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~_%.a-zA-Z0-9/]+)?(\?[-+~_%\.a-zA-Z0-9=&]+)?(#[-+~_%.a-zA-Z0-9]+)?`)
+
+ // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
+ comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
+
+ validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
+
+ // While this email regex is definitely not perfect and I'm sure you can come up
+ // with edge cases, it is still accepted by the CommonMark specification, as
+ // well as the HTML5 spec:
+ // http://spec.commonmark.org/0.28/#email-address
+ // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail)
+ emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))")
+
+ // blackfriday extensions create IDs like fn:user-content-footnote
+ blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`)
+
+ // EmojiShortCodeRegex find emoji by alias like :smile:
+ EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
+
+ InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`")
+)
+
+// CSS class for action keywords (e.g. "closes: #1")
+const keywordClass = "issue-keyword"
+
+// IsLink reports whether link fits valid format.
+func IsLink(link []byte) bool {
+ return validLinksPattern.Match(link)
+}
+
+func IsLinkStr(link string) bool {
+ return validLinksPattern.MatchString(link)
+}
+
+// regexp for full links to issues/pulls
+var issueFullPattern *regexp.Regexp
+
+// Once for to prevent races
+var issueFullPatternOnce sync.Once
+
+func getIssueFullPattern() *regexp.Regexp {
+ issueFullPatternOnce.Do(func() {
+ // example: https://domain/org/repo/pulls/27#hash
+ issueFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) +
+ `(?P<user>[\w_.-]+)\/(?P<repo>[\w_.-]+)\/(?:issues|pulls)\/(?P<num>(?:\w{1,10}-)?[1-9][0-9]*)(?P<subpath>\/[\w_.-]+)?(?:(?P<comment>#(?:issue|issuecomment)-\d+)|(?:[\?#](?:\S+)?))?\b`)
+ })
+ return issueFullPattern
+}
+
+// CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text
+func CustomLinkURLSchemes(schemes []string) {
+ schemes = append(schemes, "http", "https")
+ withAuth := make([]string, 0, len(schemes))
+ validScheme := regexp.MustCompile(`^[a-z]+$`)
+ for _, s := range schemes {
+ if !validScheme.MatchString(s) {
+ continue
+ }
+ without := false
+ for _, sna := range xurls.SchemesNoAuthority {
+ if s == sna {
+ without = true
+ break
+ }
+ }
+ if without {
+ s += ":"
+ } else {
+ s += "://"
+ }
+ withAuth = append(withAuth, s)
+ }
+ common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|"))
+}
+
+type postProcessError struct {
+ context string
+ err error
+}
+
+func (p *postProcessError) Error() string {
+ return "PostProcess: " + p.context + ", " + p.err.Error()
+}
+
+type processor func(ctx *RenderContext, node *html.Node)
+
+var defaultProcessors = []processor{
+ fullIssuePatternProcessor,
+ comparePatternProcessor,
+ filePreviewPatternProcessor,
+ fullHashPatternProcessor,
+ shortLinkProcessor,
+ linkProcessor,
+ mentionProcessor,
+ issueIndexPatternProcessor,
+ commitCrossReferencePatternProcessor,
+ hashCurrentPatternProcessor,
+ emailAddressProcessor,
+ emojiProcessor,
+ emojiShortCodeProcessor,
+}
+
+// PostProcess does the final required transformations to the passed raw HTML
+// data, and ensures its validity. Transformations include: replacing links and
+// emails with HTML links, parsing shortlinks in the format of [[Link]], like
+// MediaWiki, linking issues in the format #ID, and mentions in the format
+// @user, and others.
+func PostProcess(
+ ctx *RenderContext,
+ input io.Reader,
+ output io.Writer,
+) error {
+ return postProcess(ctx, defaultProcessors, input, output)
+}
+
+var commitMessageProcessors = []processor{
+ fullIssuePatternProcessor,
+ comparePatternProcessor,
+ fullHashPatternProcessor,
+ linkProcessor,
+ mentionProcessor,
+ issueIndexPatternProcessor,
+ commitCrossReferencePatternProcessor,
+ hashCurrentPatternProcessor,
+ emailAddressProcessor,
+ emojiProcessor,
+ emojiShortCodeProcessor,
+}
+
+// RenderCommitMessage will use the same logic as PostProcess, but will disable
+// the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is
+// set, which changes every text node into a link to the passed default link.
+func RenderCommitMessage(
+ ctx *RenderContext,
+ content string,
+) (string, error) {
+ procs := commitMessageProcessors
+ if ctx.DefaultLink != "" {
+ // we don't have to fear data races, because being
+ // commitMessageProcessors of fixed len and cap, every time we append
+ // something to it the slice is realloc+copied, so append always
+ // generates the slice ex-novo.
+ procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink))
+ }
+ return renderProcessString(ctx, procs, content)
+}
+
+var commitMessageSubjectProcessors = []processor{
+ fullIssuePatternProcessor,
+ comparePatternProcessor,
+ fullHashPatternProcessor,
+ linkProcessor,
+ mentionProcessor,
+ issueIndexPatternProcessor,
+ commitCrossReferencePatternProcessor,
+ hashCurrentPatternProcessor,
+ emojiShortCodeProcessor,
+ emojiProcessor,
+}
+
+var emojiProcessors = []processor{
+ emojiShortCodeProcessor,
+ emojiProcessor,
+}
+
+// RenderCommitMessageSubject will use the same logic as PostProcess and
+// RenderCommitMessage, but will disable the shortLinkProcessor and
+// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
+// which changes every text node into a link to the passed default link.
+func RenderCommitMessageSubject(
+ ctx *RenderContext,
+ content string,
+) (string, error) {
+ procs := commitMessageSubjectProcessors
+ if ctx.DefaultLink != "" {
+ // we don't have to fear data races, because being
+ // commitMessageSubjectProcessors of fixed len and cap, every time we
+ // append something to it the slice is realloc+copied, so append always
+ // generates the slice ex-novo.
+ procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink))
+ }
+ return renderProcessString(ctx, procs, content)
+}
+
+// RenderIssueTitle to process title on individual issue/pull page
+func RenderIssueTitle(
+ ctx *RenderContext,
+ title string,
+) (string, error) {
+ return renderProcessString(ctx, []processor{
+ inlineCodeBlockProcessor,
+ issueIndexPatternProcessor,
+ commitCrossReferencePatternProcessor,
+ hashCurrentPatternProcessor,
+ emojiShortCodeProcessor,
+ emojiProcessor,
+ }, title)
+}
+
+// RenderRefIssueTitle to process title on places where an issue is referenced
+func RenderRefIssueTitle(
+ ctx *RenderContext,
+ title string,
+) (string, error) {
+ return renderProcessString(ctx, []processor{
+ inlineCodeBlockProcessor,
+ issueIndexPatternProcessor,
+ emojiShortCodeProcessor,
+ emojiProcessor,
+ }, title)
+}
+
+func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
+ var buf strings.Builder
+ if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+// RenderDescriptionHTML will use similar logic as PostProcess, but will
+// use a single special linkProcessor.
+func RenderDescriptionHTML(
+ ctx *RenderContext,
+ content string,
+) (string, error) {
+ return renderProcessString(ctx, []processor{
+ descriptionLinkProcessor,
+ emojiShortCodeProcessor,
+ emojiProcessor,
+ }, content)
+}
+
+// RenderEmoji for when we want to just process emoji and shortcodes
+// in various places it isn't already run through the normal markdown processor
+func RenderEmoji(
+ ctx *RenderContext,
+ content string,
+) (string, error) {
+ return renderProcessString(ctx, emojiProcessors, content)
+}
+
+var (
+ tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`)
+ nulCleaner = strings.NewReplacer("\000", "")
+)
+
+func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error {
+ defer ctx.Cancel()
+ // FIXME: don't read all content to memory
+ rawHTML, err := io.ReadAll(input)
+ if err != nil {
+ return err
+ }
+
+ // parse the HTML
+ node, err := html.Parse(io.MultiReader(
+ // prepend "<html><body>"
+ strings.NewReader("<html><body>"),
+ // Strip out nuls - they're always invalid
+ bytes.NewReader(tagCleaner.ReplaceAll([]byte(nulCleaner.Replace(string(rawHTML))), []byte("&lt;$1"))),
+ // close the tags
+ strings.NewReader("</body></html>"),
+ ))
+ if err != nil {
+ return &postProcessError{"invalid HTML", err}
+ }
+
+ if node.Type == html.DocumentNode {
+ node = node.FirstChild
+ }
+
+ visitNode(ctx, procs, node)
+
+ newNodes := make([]*html.Node, 0, 5)
+
+ if node.Data == "html" {
+ node = node.FirstChild
+ for node != nil && node.Data != "body" {
+ node = node.NextSibling
+ }
+ }
+ if node != nil {
+ if node.Data == "body" {
+ child := node.FirstChild
+ for child != nil {
+ newNodes = append(newNodes, child)
+ child = child.NextSibling
+ }
+ } else {
+ newNodes = append(newNodes, node)
+ }
+ }
+
+ // Render everything to buf.
+ for _, node := range newNodes {
+ if err := html.Render(output, node); err != nil {
+ return &postProcessError{"error rendering processed HTML", err}
+ }
+ }
+ return nil
+}
+
+func visitNode(ctx *RenderContext, procs []processor, node *html.Node) {
+ // Add user-content- to IDs and "#" links if they don't already have them
+ for idx, attr := range node.Attr {
+ val := strings.TrimPrefix(attr.Val, "#")
+ notHasPrefix := !(strings.HasPrefix(val, "user-content-") || blackfridayExtRegex.MatchString(val))
+
+ if attr.Key == "id" && notHasPrefix {
+ node.Attr[idx].Val = "user-content-" + attr.Val
+ }
+
+ if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix {
+ node.Attr[idx].Val = "#user-content-" + val
+ }
+
+ if attr.Key == "class" && attr.Val == "emoji" {
+ procs = nil
+ }
+ }
+
+ // We ignore code and pre.
+ switch node.Type {
+ case html.TextNode:
+ processTextNodes(ctx, procs, node)
+ case html.ElementNode:
+ if node.Data == "img" {
+ for i, attr := range node.Attr {
+ if attr.Key != "src" {
+ continue
+ }
+ if len(attr.Val) > 0 && !IsLinkStr(attr.Val) && !strings.HasPrefix(attr.Val, "data:image/") {
+ attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val)
+ }
+ attr.Val = camoHandleLink(attr.Val)
+ node.Attr[i] = attr
+ }
+ } else if node.Data == "a" {
+ // Restrict text in links to emojis
+ procs = emojiProcessors
+ } else if node.Data == "code" || node.Data == "pre" {
+ return
+ } else if node.Data == "i" {
+ for _, attr := range node.Attr {
+ if attr.Key != "class" {
+ continue
+ }
+ classes := strings.Split(attr.Val, " ")
+ for i, class := range classes {
+ if class == "icon" {
+ classes[0], classes[i] = classes[i], classes[0]
+ attr.Val = strings.Join(classes, " ")
+
+ // Remove all children of icons
+ child := node.FirstChild
+ for child != nil {
+ node.RemoveChild(child)
+ child = node.FirstChild
+ }
+ break
+ }
+ }
+ }
+ }
+ for n := node.FirstChild; n != nil; n = n.NextSibling {
+ visitNode(ctx, procs, n)
+ }
+ default:
+ }
+ // ignore everything else
+}
+
+// processTextNodes runs the passed node through various processors, in order to handle
+// all kinds of special links handled by the post-processing.
+func processTextNodes(ctx *RenderContext, procs []processor, node *html.Node) {
+ for _, p := range procs {
+ p(ctx, node)
+ }
+}
+
+// createKeyword() renders a highlighted version of an action keyword
+func createKeyword(content string) *html.Node {
+ span := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Span.String(),
+ Attr: []html.Attribute{},
+ }
+ span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: keywordClass})
+
+ text := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+ span.AppendChild(text)
+
+ return span
+}
+
+func createInlineCode(content string) *html.Node {
+ code := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Code.String(),
+ Attr: []html.Attribute{},
+ }
+
+ code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"})
+
+ text := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+
+ code.AppendChild(text)
+ return code
+}
+
+func createEmoji(content, class, name string) *html.Node {
+ span := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Span.String(),
+ Attr: []html.Attribute{},
+ }
+ if class != "" {
+ span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class})
+ }
+ if name != "" {
+ span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name})
+ }
+
+ text := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+
+ span.AppendChild(text)
+ return span
+}
+
+func createCustomEmoji(alias string) *html.Node {
+ span := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Span.String(),
+ Attr: []html.Attribute{},
+ }
+ span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"})
+ span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias})
+
+ img := &html.Node{
+ Type: html.ElementNode,
+ DataAtom: atom.Img,
+ Data: "img",
+ Attr: []html.Attribute{},
+ }
+ img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"})
+ img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"})
+
+ span.AppendChild(img)
+ return span
+}
+
+func createLink(href, content, class string) *html.Node {
+ a := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.A.String(),
+ Attr: []html.Attribute{{Key: "href", Val: href}},
+ }
+
+ if class != "" {
+ a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
+ }
+
+ text := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+
+ a.AppendChild(text)
+ return a
+}
+
+func createCodeLink(href, content, class string) *html.Node {
+ a := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.A.String(),
+ Attr: []html.Attribute{{Key: "href", Val: href}},
+ }
+
+ if class != "" {
+ a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
+ }
+
+ text := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+
+ code := &html.Node{
+ Type: html.ElementNode,
+ Data: atom.Code.String(),
+ Attr: []html.Attribute{{Key: "class", Val: "nohighlight"}},
+ }
+
+ code.AppendChild(text)
+ a.AppendChild(code)
+ return a
+}
+
+// replaceContent takes text node, and in its content it replaces a section of
+// it with the specified newNode.
+func replaceContent(node *html.Node, i, j int, newNode *html.Node) {
+ replaceContentList(node, i, j, []*html.Node{newNode})
+}
+
+// replaceContentList takes text node, and in its content it replaces a section of
+// it with the specified newNodes. An example to visualize how this can work can
+// be found here: https://play.golang.org/p/5zP8NnHZ03s
+func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) {
+ // get the data before and after the match
+ before := node.Data[:i]
+ after := node.Data[j:]
+
+ // Replace in the current node the text, so that it is only what it is
+ // supposed to have.
+ node.Data = before
+
+ // Get the current next sibling, before which we place the replaced data,
+ // and after that we place the new text node.
+ nextSibling := node.NextSibling
+ for _, n := range newNodes {
+ node.Parent.InsertBefore(n, nextSibling)
+ }
+ if after != "" {
+ node.Parent.InsertBefore(&html.Node{
+ Type: html.TextNode,
+ Data: after,
+ }, nextSibling)
+ }
+}
+
+func mentionProcessor(ctx *RenderContext, node *html.Node) {
+ start := 0
+ next := node.NextSibling
+ for node != nil && node != next && start < len(node.Data) {
+ // We replace only the first mention; other mentions will be addressed later
+ found, loc := references.FindFirstMentionBytes([]byte(node.Data[start:]))
+ if !found {
+ return
+ }
+ loc.Start += start
+ loc.End += start
+ mention := node.Data[loc.Start:loc.End]
+ var teams string
+ teams, ok := ctx.Metas["teams"]
+ // FIXME: util.URLJoin may not be necessary here:
+ // - setting.AppURL is defined to have a terminal '/' so unless mention[1:]
+ // is an AppSubURL link we can probably fallback to concatenation.
+ // team mention should follow @orgName/teamName style
+ if ok && strings.Contains(mention, "/") {
+ mentionOrgAndTeam := strings.Split(mention, "/")
+ if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
+ replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention"))
+ node = node.NextSibling.NextSibling
+ start = 0
+ continue
+ }
+ start = loc.End
+ continue
+ }
+ mentionedUsername := mention[1:]
+
+ if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
+ replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention"))
+ node = node.NextSibling.NextSibling
+ start = 0
+ } else {
+ start = loc.End
+ }
+ }
+}
+
+func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := shortLinkPattern.FindStringSubmatchIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ content := node.Data[m[2]:m[3]]
+ tail := node.Data[m[4]:m[5]]
+ props := make(map[string]string)
+
+ // MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
+ // It makes page handling terrible, but we prefer GitHub syntax
+ // And fall back to MediaWiki only when it is obvious from the look
+ // Of text and link contents
+ sl := strings.Split(content, "|")
+ for _, v := range sl {
+ if equalPos := strings.IndexByte(v, '='); equalPos == -1 {
+ // There is no equal in this argument; this is a mandatory arg
+ if props["name"] == "" {
+ if IsLinkStr(v) {
+ // If we clearly see it is a link, we save it so
+
+ // But first we need to ensure, that if both mandatory args provided
+ // look like links, we stick to GitHub syntax
+ if props["link"] != "" {
+ props["name"] = props["link"]
+ }
+
+ props["link"] = strings.TrimSpace(v)
+ } else {
+ props["name"] = v
+ }
+ } else {
+ props["link"] = strings.TrimSpace(v)
+ }
+ } else {
+ // There is an equal; optional argument.
+
+ sep := strings.IndexByte(v, '=')
+ key, val := v[:sep], html.UnescapeString(v[sep+1:])
+
+ // When parsing HTML, x/net/html will change all quotes which are
+ // not used for syntax into UTF-8 quotes. So checking val[0] won't
+ // be enough, since that only checks a single byte.
+ if len(val) > 1 {
+ if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "â€")) ||
+ (strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) {
+ const lenQuote = len("‘")
+ val = val[lenQuote : len(val)-lenQuote]
+ } else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) ||
+ (strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) {
+ val = val[1 : len(val)-1]
+ } else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") {
+ const lenQuote = len("‘")
+ val = val[1 : len(val)-lenQuote]
+ }
+ }
+ props[key] = val
+ }
+ }
+
+ var name, link string
+ if props["link"] != "" {
+ link = props["link"]
+ } else if props["name"] != "" {
+ link = props["name"]
+ }
+ if props["title"] != "" {
+ name = props["title"]
+ } else if props["name"] != "" {
+ name = props["name"]
+ } else {
+ name = link
+ }
+
+ name += tail
+ image := false
+ switch ext := filepath.Ext(link); ext {
+ // fast path: empty string, ignore
+ case "":
+ // leave image as false
+ case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
+ image = true
+ }
+
+ childNode := &html.Node{}
+ linkNode := &html.Node{
+ FirstChild: childNode,
+ LastChild: childNode,
+ Type: html.ElementNode,
+ Data: "a",
+ DataAtom: atom.A,
+ }
+ childNode.Parent = linkNode
+ absoluteLink := IsLinkStr(link)
+ if !absoluteLink {
+ if image {
+ link = strings.ReplaceAll(link, " ", "+")
+ } else {
+ link = strings.ReplaceAll(link, " ", "-")
+ }
+ if !strings.Contains(link, "/") {
+ link = url.PathEscape(link)
+ }
+ }
+ if image {
+ if !absoluteLink {
+ link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link)
+ }
+ title := props["title"]
+ if title == "" {
+ title = props["alt"]
+ }
+ if title == "" {
+ title = path.Base(name)
+ }
+ alt := props["alt"]
+ if alt == "" {
+ alt = name
+ }
+
+ // make the childNode an image - if we can, we also place the alt
+ childNode.Type = html.ElementNode
+ childNode.Data = "img"
+ childNode.DataAtom = atom.Img
+ childNode.Attr = []html.Attribute{
+ {Key: "src", Val: link},
+ {Key: "title", Val: title},
+ {Key: "alt", Val: alt},
+ }
+ if alt == "" {
+ childNode.Attr = childNode.Attr[:2]
+ }
+ } else {
+ if !absoluteLink {
+ if ctx.IsWiki {
+ link = util.URLJoin(ctx.Links.WikiLink(), link)
+ } else {
+ link = util.URLJoin(ctx.Links.SrcLink(), link)
+ }
+ }
+ childNode.Type = html.TextNode
+ childNode.Data = name
+ }
+ linkNode.Attr = []html.Attribute{{Key: "href", Val: link}}
+ replaceContent(node, m[0], m[1], linkNode)
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil {
+ return
+ }
+ next := node.NextSibling
+ for node != nil && node != next {
+ re := getIssueFullPattern()
+ linkIndex, m := re.FindStringIndex(node.Data), re.FindStringSubmatch(node.Data)
+ if linkIndex == nil || m == nil {
+ return
+ }
+
+ link := node.Data[linkIndex[0]:linkIndex[1]]
+ text := "#" + m[re.SubexpIndex("num")] + m[re.SubexpIndex("subpath")]
+
+ if len(m[re.SubexpIndex("comment")]) > 0 {
+ if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
+ text += " " + locale.TrString("repo.from_comment")
+ } else {
+ text += " (comment)"
+ }
+ }
+
+ matchUser := m[re.SubexpIndex("user")]
+ matchRepo := m[re.SubexpIndex("repo")]
+
+ if matchUser == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] {
+ replaceContent(node, linkIndex[0], linkIndex[1], createLink(link, text, "ref-issue"))
+ } else {
+ text = matchUser + "/" + matchRepo + text
+ replaceContent(node, linkIndex[0], linkIndex[1], createLink(link, text, "ref-issue"))
+ }
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil {
+ return
+ }
+
+ // FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered?
+ // The "mode" approach should be refactored to some other more clear&reliable way.
+ crossLinkOnly := (ctx.Metas["mode"] == "document" && !ctx.IsWiki)
+
+ var (
+ found bool
+ ref *references.RenderizableReference
+ )
+
+ next := node.NextSibling
+
+ for node != nil && node != next {
+ _, hasExtTrackFormat := ctx.Metas["format"]
+
+ // Repos with external issue trackers might still need to reference local PRs
+ // We need to concern with the first one that shows up in the text, whichever it is
+ isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric
+ foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
+
+ switch ctx.Metas["style"] {
+ case "", IssueNameStyleNumeric:
+ found, ref = foundNumeric, refNumeric
+ case IssueNameStyleAlphanumeric:
+ found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
+ case IssueNameStyleRegexp:
+ pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"])
+ if err != nil {
+ return
+ }
+ found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
+ }
+
+ // Repos with external issue trackers might still need to reference local PRs
+ // We need to concern with the first one that shows up in the text, whichever it is
+ if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
+ // If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
+ // Allow a free-pass when non-numeric pattern wasn't found.
+ if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) {
+ found = foundNumeric
+ ref = refNumeric
+ }
+ }
+ if !found {
+ return
+ }
+
+ var link *html.Node
+ reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
+ if hasExtTrackFormat && !ref.IsPull && ref.Owner == "" {
+ ctx.Metas["index"] = ref.Issue
+
+ res, err := vars.Expand(ctx.Metas["format"], ctx.Metas)
+ if err != nil {
+ // here we could just log the error and continue the rendering
+ log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
+ }
+
+ link = createLink(res, reftext, "ref-issue ref-external-issue")
+ } else {
+ // Path determines the type of link that will be rendered. It's unknown at this point whether
+ // the linked item is actually a PR or an issue. Luckily it's of no real consequence because
+ // Forgejo will redirect on click as appropriate.
+ path := "issues"
+ if ref.IsPull {
+ path = "pulls"
+ }
+ if ref.Owner == "" {
+ link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], path, ref.Issue), reftext, "ref-issue")
+ } else {
+ link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, path, ref.Issue), reftext, "ref-issue")
+ }
+ }
+
+ if ref.Action == references.XRefActionNone {
+ replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
+ node = node.NextSibling.NextSibling
+ continue
+ }
+
+ // Decorate action keywords if actionable
+ var keyword *html.Node
+ if references.IsXrefActionable(ref, hasExtTrackFormat) {
+ keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
+ } else {
+ keyword = &html.Node{
+ Type: html.TextNode,
+ Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
+ }
+ }
+ spaces := &html.Node{
+ Type: html.TextNode,
+ Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],
+ }
+ replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link})
+ node = node.NextSibling.NextSibling.NextSibling.NextSibling
+ }
+}
+
+func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
+ next := node.NextSibling
+
+ for node != nil && node != next {
+ found, ref := references.FindRenderizableCommitCrossReference(node.Data)
+ if !found {
+ return
+ }
+
+ reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
+ link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
+
+ replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
+ node = node.NextSibling.NextSibling
+ }
+}
+
+// fullHashPatternProcessor renders SHA containing URLs
+func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil {
+ return
+ }
+
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := anyHashPattern.FindStringSubmatchIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ urlFull := node.Data[m[0]:m[1]]
+ text := base.ShortSha(node.Data[m[2]:m[3]])
+
+ // 3rd capture group matches a optional path
+ subpath := ""
+ if m[5] > 0 {
+ subpath = node.Data[m[4]:m[5]]
+ }
+
+ // 5th capture group matches a optional url hash
+ hash := ""
+ if m[9] > 0 {
+ hash = node.Data[m[8]:m[9]][1:]
+ }
+
+ start := m[0]
+ end := m[1]
+
+ // If url ends in '.', it's very likely that it is not part of the
+ // actual url but used to finish a sentence.
+ if strings.HasSuffix(urlFull, ".") {
+ end--
+ urlFull = urlFull[:len(urlFull)-1]
+ if hash != "" {
+ hash = hash[:len(hash)-1]
+ } else if subpath != "" {
+ subpath = subpath[:len(subpath)-1]
+ }
+ }
+
+ if subpath != "" {
+ text += subpath
+ }
+
+ if hash != "" {
+ text += " (" + hash + ")"
+ }
+ replaceContent(node, start, end, createCodeLink(urlFull, text, "commit"))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil {
+ return
+ }
+
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := comparePattern.FindStringSubmatchIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ // Ensure that every group (m[0]...m[7]) has a match
+ for i := 0; i < 8; i++ {
+ if m[i] == -1 {
+ return
+ }
+ }
+
+ urlFull := node.Data[m[0]:m[1]]
+ text1 := base.ShortSha(node.Data[m[2]:m[3]])
+ textDots := base.ShortSha(node.Data[m[4]:m[5]])
+ text2 := base.ShortSha(node.Data[m[6]:m[7]])
+
+ hash := ""
+ if m[9] > 0 {
+ hash = node.Data[m[8]:m[9]][1:]
+ }
+
+ start := m[0]
+ end := m[1]
+
+ // If url ends in '.', it's very likely that it is not part of the
+ // actual url but used to finish a sentence.
+ if strings.HasSuffix(urlFull, ".") {
+ end--
+ urlFull = urlFull[:len(urlFull)-1]
+ if hash != "" {
+ hash = hash[:len(hash)-1]
+ } else if text2 != "" {
+ text2 = text2[:len(text2)-1]
+ }
+ }
+
+ text := text1 + textDots + text2
+ if hash != "" {
+ text += " (" + hash + ")"
+ }
+ replaceContent(node, start, end, createCodeLink(urlFull, text, "compare"))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" {
+ return
+ }
+ if DefaultProcessorHelper.GetRepoFileBlob == nil {
+ return
+ }
+
+ locale := translation.NewLocale("en-US")
+ if ctx.Ctx != nil {
+ ctxLocale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale)
+ if ok {
+ locale = ctxLocale
+ }
+ }
+
+ next := node.NextSibling
+ for node != nil && node != next {
+ previews := NewFilePreviews(ctx, node, locale)
+ if previews == nil {
+ node = node.NextSibling
+ continue
+ }
+
+ offset := 0
+ for _, preview := range previews {
+ previewNode := preview.CreateHTML(locale)
+
+ // Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div
+ before := node.Data[:(preview.start - offset)]
+ after := node.Data[(preview.end - offset):]
+ afterPrefix := "<p>"
+ offset = preview.end - len(afterPrefix)
+ node.Data = before
+ nextSibling := node.NextSibling
+ node.Parent.InsertBefore(&html.Node{
+ Type: html.RawNode,
+ Data: "</p>",
+ }, nextSibling)
+ node.Parent.InsertBefore(previewNode, nextSibling)
+ afterNode := &html.Node{
+ Type: html.RawNode,
+ Data: afterPrefix + after,
+ }
+ node.Parent.InsertBefore(afterNode, nextSibling)
+ node = afterNode
+ }
+
+ node = node.NextSibling
+ }
+}
+
+func inlineCodeBlockProcessor(ctx *RenderContext, node *html.Node) {
+ start := 0
+ next := node.NextSibling
+ for node != nil && node != next && start < len(node.Data) {
+ m := InlineCodeBlockRegex.FindStringSubmatchIndex(node.Data[start:])
+ if m == nil {
+ return
+ }
+
+ code := node.Data[m[0]+1 : m[1]-1]
+ replaceContent(node, m[0], m[1], createInlineCode(code))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+// emojiShortCodeProcessor for rendering text like :smile: into emoji
+func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
+ start := 0
+ next := node.NextSibling
+ for node != nil && node != next && start < len(node.Data) {
+ m := EmojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:])
+ if m == nil {
+ return
+ }
+ m[0] += start
+ m[1] += start
+
+ start = m[1]
+
+ alias := node.Data[m[0]:m[1]]
+ alias = strings.ReplaceAll(alias, ":", "")
+ converted := emoji.FromAlias(alias)
+ if converted == nil {
+ // check if this is a custom reaction
+ if _, exist := setting.UI.CustomEmojisMap[alias]; exist {
+ replaceContent(node, m[0], m[1], createCustomEmoji(alias))
+ node = node.NextSibling.NextSibling
+ start = 0
+ continue
+ }
+ continue
+ }
+
+ replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description))
+ node = node.NextSibling.NextSibling
+ start = 0
+ }
+}
+
+// emoji processor to match emoji and add emoji class
+func emojiProcessor(ctx *RenderContext, node *html.Node) {
+ start := 0
+ next := node.NextSibling
+ for node != nil && node != next && start < len(node.Data) {
+ m := emoji.FindEmojiSubmatchIndex(node.Data[start:])
+ if m == nil {
+ return
+ }
+ m[0] += start
+ m[1] += start
+
+ codepoint := node.Data[m[0]:m[1]]
+ start = m[1]
+ val := emoji.FromCode(codepoint)
+ if val != nil {
+ replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description))
+ node = node.NextSibling.NextSibling
+ start = 0
+ }
+ }
+}
+
+// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that
+// are assumed to be in the same repository.
+func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
+ if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" || ctx.Metas["repoPath"] == "" {
+ return
+ }
+
+ start := 0
+ next := node.NextSibling
+ if ctx.ShaExistCache == nil {
+ ctx.ShaExistCache = make(map[string]bool)
+ }
+ for node != nil && node != next && start < len(node.Data) {
+ m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:])
+ if m == nil {
+ return
+ }
+ m[2] += start
+ m[3] += start
+
+ hash := node.Data[m[2]:m[3]]
+ // The regex does not lie, it matches the hash pattern.
+ // However, a regex cannot know if a hash actually exists or not.
+ // We could assume that a SHA1 hash should probably contain alphas AND numerics
+ // but that is not always the case.
+ // Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
+ // as used by git and github for linking and thus we have to do similar.
+ // Because of this, we check to make sure that a matched hash is actually
+ // a commit in the repository before making it a link.
+
+ // check cache first
+ exist, inCache := ctx.ShaExistCache[hash]
+ if !inCache {
+ if ctx.GitRepo == nil {
+ var err error
+ ctx.GitRepo, err = git.OpenRepository(ctx.Ctx, ctx.Metas["repoPath"])
+ if err != nil {
+ log.Error("unable to open repository: %s Error: %v", ctx.Metas["repoPath"], err)
+ return
+ }
+ ctx.AddCancel(func() {
+ ctx.GitRepo.Close()
+ ctx.GitRepo = nil
+ })
+ }
+
+ exist = ctx.GitRepo.IsReferenceExist(hash)
+ ctx.ShaExistCache[hash] = exist
+ }
+
+ if !exist {
+ start = m[3]
+ continue
+ }
+
+ link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash)
+ replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
+ start = 0
+ node = node.NextSibling.NextSibling
+ }
+}
+
+// emailAddressProcessor replaces raw email addresses with a mailto: link.
+func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := emailRegex.FindStringSubmatchIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ mail := node.Data[m[2]:m[3]]
+ replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto"))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+// linkProcessor creates links for any HTTP or HTTPS URL not captured by
+// markdown.
+func linkProcessor(ctx *RenderContext, node *html.Node) {
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := common.LinkRegex.FindStringIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ uri := node.Data[m[0]:m[1]]
+ replaceContent(node, m[0], m[1], createLink(uri, uri, "link"))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func genDefaultLinkProcessor(defaultLink string) processor {
+ return func(ctx *RenderContext, node *html.Node) {
+ ch := &html.Node{
+ Parent: node,
+ Type: html.TextNode,
+ Data: node.Data,
+ }
+
+ node.Type = html.ElementNode
+ node.Data = "a"
+ node.DataAtom = atom.A
+ node.Attr = []html.Attribute{
+ {Key: "href", Val: defaultLink},
+ {Key: "class", Val: "default-link muted"},
+ }
+ node.FirstChild, node.LastChild = ch, ch
+ }
+}
+
+// descriptionLinkProcessor creates links for DescriptionHTML
+func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
+ next := node.NextSibling
+ for node != nil && node != next {
+ m := common.LinkRegex.FindStringIndex(node.Data)
+ if m == nil {
+ return
+ }
+
+ uri := node.Data[m[0]:m[1]]
+ replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri))
+ node = node.NextSibling.NextSibling
+ }
+}
+
+func createDescriptionLink(href, content string) *html.Node {
+ textNode := &html.Node{
+ Type: html.TextNode,
+ Data: content,
+ }
+ linkNode := &html.Node{
+ FirstChild: textNode,
+ LastChild: textNode,
+ Type: html.ElementNode,
+ Data: "a",
+ DataAtom: atom.A,
+ Attr: []html.Attribute{
+ {Key: "href", Val: href},
+ {Key: "target", Val: "_blank"},
+ {Key: "rel", Val: "noopener noreferrer"},
+ },
+ }
+ textNode.Parent = linkNode
+ return linkNode
+}
diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go
new file mode 100644
index 0000000..a72be9f
--- /dev/null
+++ b/modules/markup/html_internal_test.go
@@ -0,0 +1,486 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ TestAppURL = "http://localhost:3000/"
+ TestOrgRepo = "gogits/gogs"
+ TestRepoURL = TestAppURL + TestOrgRepo + "/"
+)
+
+// externalIssueLink an HTML link to an alphanumeric-style issue
+func externalIssueLink(baseURL, class, name string) string {
+ return link(util.URLJoin(baseURL, name), class, name)
+}
+
+// numericLink an HTML to a numeric-style issue
+func numericIssueLink(baseURL, class string, index int, marker string) string {
+ return link(util.URLJoin(baseURL, strconv.Itoa(index)), class, fmt.Sprintf("%s%d", marker, index))
+}
+
+// link an HTML link
+func link(href, class, contents string) string {
+ if class != "" {
+ class = " class=\"" + class + "\""
+ }
+
+ return fmt.Sprintf("<a href=\"%s\"%s>%s</a>", href, class, contents)
+}
+
+var numericMetas = map[string]string{
+ "format": "https://someurl.com/{user}/{repo}/{index}",
+ "user": "someUser",
+ "repo": "someRepo",
+ "style": IssueNameStyleNumeric,
+}
+
+var alphanumericMetas = map[string]string{
+ "format": "https://someurl.com/{user}/{repo}/{index}",
+ "user": "someUser",
+ "repo": "someRepo",
+ "style": IssueNameStyleAlphanumeric,
+}
+
+var regexpMetas = map[string]string{
+ "format": "https://someurl.com/{user}/{repo}/{index}",
+ "user": "someUser",
+ "repo": "someRepo",
+ "style": IssueNameStyleRegexp,
+}
+
+// these values should match the TestOrgRepo const above
+var localMetas = map[string]string{
+ "user": "gogits",
+ "repo": "gogs",
+}
+
+func TestRender_IssueIndexPattern(t *testing.T) {
+ // numeric: render inputs without valid mentions
+ test := func(s string) {
+ testRenderIssueIndexPattern(t, s, s, &RenderContext{
+ Ctx: git.DefaultContext,
+ })
+ testRenderIssueIndexPattern(t, s, s, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: numericMetas,
+ })
+ }
+
+ // should not render anything when there are no mentions
+ test("")
+ test("this is a test")
+ test("test 123 123 1234")
+ test("#")
+ test("# # #")
+ test("# 123")
+ test("#abcd")
+ test("test#1234")
+ test("#1234test")
+ test("#abcd")
+ test("test!1234")
+ test("!1234test")
+ test(" test !1234test")
+ test("/home/gitea/#1234")
+ test("/home/gitea/!1234")
+
+ // should not render issue mention without leading space
+ test("test#54321 issue")
+
+ // should not render issue mention without trailing space
+ test("test #54321issue")
+}
+
+func TestRender_IssueIndexPattern2(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ // numeric: render inputs with valid mentions
+ test := func(s, expectedFmt, marker string, indices ...int) {
+ var path, prefix string
+ isExternal := false
+ if marker == "!" {
+ path = "pulls"
+ prefix = "http://localhost:3000/someUser/someRepo/pulls/"
+ } else {
+ path = "issues"
+ prefix = "https://someurl.com/someUser/someRepo/"
+ isExternal = true
+ }
+
+ links := make([]any, len(indices))
+ for i, index := range indices {
+ links[i] = numericIssueLink(util.URLJoin(TestRepoURL, path), "ref-issue", index, marker)
+ }
+ expectedNil := fmt.Sprintf(expectedFmt, links...)
+ testRenderIssueIndexPattern(t, s, expectedNil, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: localMetas,
+ })
+
+ class := "ref-issue"
+ if isExternal {
+ class += " ref-external-issue"
+ }
+
+ for i, index := range indices {
+ links[i] = numericIssueLink(prefix, class, index, marker)
+ }
+ expectedNum := fmt.Sprintf(expectedFmt, links...)
+ testRenderIssueIndexPattern(t, s, expectedNum, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: numericMetas,
+ })
+ }
+
+ // should render freestanding mentions
+ test("#1234 test", "%s test", "#", 1234)
+ test("test #8 issue", "test %s issue", "#", 8)
+ test("!1234 test", "%s test", "!", 1234)
+ test("test !8 issue", "test %s issue", "!", 8)
+ test("test issue #1234", "test issue %s", "#", 1234)
+ test("fixes issue #1234.", "fixes issue %s.", "#", 1234)
+
+ // should render mentions in parentheses / brackets
+ test("(#54321 issue)", "(%s issue)", "#", 54321)
+ test("[#54321 issue]", "[%s issue]", "#", 54321)
+ test("test (#9801 extra) issue", "test (%s extra) issue", "#", 9801)
+ test("test (!9801 extra) issue", "test (%s extra) issue", "!", 9801)
+ test("test (#1)", "test (%s)", "#", 1)
+
+ // should render multiple issue mentions in the same line
+ test("#54321 #1243", "%s %s", "#", 54321, 1243)
+ test("wow (#54321 #1243)", "wow (%s %s)", "#", 54321, 1243)
+ test("(#4)(#5)", "(%s)(%s)", "#", 4, 5)
+ test("#1 (#4321) test", "%s (%s) test", "#", 1, 4321)
+
+ // should render with :
+ test("#1234: test", "%s: test", "#", 1234)
+ test("wow (#54321: test)", "wow (%s: test)", "#", 54321)
+}
+
+func TestRender_IssueIndexPattern3(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ // alphanumeric: render inputs without valid mentions
+ test := func(s string) {
+ testRenderIssueIndexPattern(t, s, s, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: alphanumericMetas,
+ })
+ }
+ test("")
+ test("this is a test")
+ test("test 123 123 1234")
+ test("#")
+ test("# 123")
+ test("#abcd")
+ test("test #123")
+ test("abc-1234") // issue prefix must be capital
+ test("ABc-1234") // issue prefix must be _all_ capital
+ test("ABCDEFGHIJK-1234") // the limit is 10 characters in the prefix
+ test("ABC1234") // dash is required
+ test("test ABC- test") // number is required
+ test("test -1234 test") // prefix is required
+ test("testABC-123 test") // leading space is required
+ test("test ABC-123test") // trailing space is required
+ test("ABC-0123") // no leading zero
+}
+
+func TestRender_IssueIndexPattern4(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ // alphanumeric: render inputs with valid mentions
+ test := func(s, expectedFmt string, names ...string) {
+ links := make([]any, len(names))
+ for i, name := range names {
+ links[i] = externalIssueLink("https://someurl.com/someUser/someRepo/", "ref-issue ref-external-issue", name)
+ }
+ expected := fmt.Sprintf(expectedFmt, links...)
+ testRenderIssueIndexPattern(t, s, expected, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: alphanumericMetas,
+ })
+ }
+ test("OTT-1234 test", "%s test", "OTT-1234")
+ test("test T-12 issue", "test %s issue", "T-12")
+ test("test issue ABCDEFGHIJ-1234567890", "test issue %s", "ABCDEFGHIJ-1234567890")
+}
+
+func TestRender_IssueIndexPattern5(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ // regexp: render inputs without valid mentions
+ test := func(s, expectedFmt, pattern string, ids, names []string) {
+ metas := regexpMetas
+ metas["regexp"] = pattern
+ links := make([]any, len(ids))
+ for i, id := range ids {
+ links[i] = link(util.URLJoin("https://someurl.com/someUser/someRepo/", id), "ref-issue ref-external-issue", names[i])
+ }
+
+ expected := fmt.Sprintf(expectedFmt, links...)
+ testRenderIssueIndexPattern(t, s, expected, &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: metas,
+ })
+ }
+
+ test("abc ISSUE-123 def", "abc %s def",
+ "ISSUE-(\\d+)",
+ []string{"123"},
+ []string{"ISSUE-123"},
+ )
+
+ test("abc (ISSUE 123) def", "abc %s def",
+ "\\(ISSUE (\\d+)\\)",
+ []string{"123"},
+ []string{"(ISSUE 123)"},
+ )
+
+ test("abc ISSUE-123 def", "abc %s def",
+ "(ISSUE-(\\d+))",
+ []string{"ISSUE-123"},
+ []string{"ISSUE-123"},
+ )
+
+ testRenderIssueIndexPattern(t, "will not match", "will not match", &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: regexpMetas,
+ })
+}
+
+func TestRender_IssueIndexPattern_Document(t *testing.T) {
+ setting.AppURL = TestAppURL
+ metas := map[string]string{
+ "format": "https://someurl.com/{user}/{repo}/{index}",
+ "user": "someUser",
+ "repo": "someRepo",
+ "style": IssueNameStyleNumeric,
+ "mode": "document",
+ }
+
+ testRenderIssueIndexPattern(t, "#1", "#1", &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: metas,
+ })
+ testRenderIssueIndexPattern(t, "#1312", "#1312", &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: metas,
+ })
+ testRenderIssueIndexPattern(t, "!1", "!1", &RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: metas,
+ })
+}
+
+func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
+ ctx.Links.AbsolutePrefix = true
+ if ctx.Links.Base == "" {
+ ctx.Links.Base = TestRepoURL
+ }
+
+ var buf strings.Builder
+ err := postProcess(ctx, []processor{issueIndexPatternProcessor}, strings.NewReader(input), &buf)
+ require.NoError(t, err)
+ assert.Equal(t, expected, buf.String(), "input=%q", input)
+}
+
+func TestRender_AutoLink(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ test := func(input, expected string) {
+ var buffer strings.Builder
+ err := PostProcess(&RenderContext{
+ Ctx: git.DefaultContext,
+ Links: Links{
+ Base: TestRepoURL,
+ },
+ Metas: localMetas,
+ }, strings.NewReader(input), &buffer)
+ require.NoError(t, err, nil)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
+
+ buffer.Reset()
+ err = PostProcess(&RenderContext{
+ Ctx: git.DefaultContext,
+ Links: Links{
+ Base: TestRepoURL,
+ },
+ Metas: localMetas,
+ IsWiki: true,
+ }, strings.NewReader(input), &buffer)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
+ }
+
+ // render valid issue URLs
+ test(util.URLJoin(TestRepoURL, "issues", "3333"),
+ numericIssueLink(util.URLJoin(TestRepoURL, "issues"), "ref-issue", 3333, "#"))
+
+ // render valid commit URLs
+ tmp := util.URLJoin(TestRepoURL, "commit", "d8a994ef243349f321568f9e36d5c3f444b99cae")
+ test(tmp, "<a href=\""+tmp+"\" class=\"commit\"><code class=\"nohighlight\">d8a994ef24</code></a>")
+ tmp += "#diff-2"
+ test(tmp, "<a href=\""+tmp+"\" class=\"commit\"><code class=\"nohighlight\">d8a994ef24 (diff-2)</code></a>")
+
+ // render other commit URLs
+ tmp = "https://external-link.gitea.io/go-gitea/gitea/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2"
+ test(tmp, "<a href=\""+tmp+"\" class=\"commit\"><code class=\"nohighlight\">d8a994ef24 (diff-2)</code></a>")
+}
+
+func TestRender_IssueIndexPatternRef(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ test := func(input, expected string) {
+ var buf strings.Builder
+ err := postProcess(&RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: numericMetas,
+ }, []processor{issueIndexPatternProcessor}, strings.NewReader(input), &buf)
+ require.NoError(t, err)
+ assert.Equal(t, expected, buf.String(), "input=%q", input)
+ }
+
+ test("alan-turin/Enigma-cryptanalysis#1", `<a href="/alan-turin/enigma-cryptanalysis/issues/1" class="ref-issue">alan-turin/Enigma-cryptanalysis#1</a>`)
+}
+
+func TestRender_FullIssueURLs(t *testing.T) {
+ setting.AppURL = TestAppURL
+
+ test := func(input, expected string) {
+ var result strings.Builder
+ err := postProcess(&RenderContext{
+ Ctx: git.DefaultContext,
+ Links: Links{
+ Base: TestRepoURL,
+ },
+ Metas: localMetas,
+ }, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
+ require.NoError(t, err)
+ assert.Equal(t, expected, result.String())
+ }
+ test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
+ "Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
+ test("Look here http://localhost:3000/person/repo/issues/4",
+ `Look here <a href="http://localhost:3000/person/repo/issues/4" class="ref-issue">person/repo#4</a>`)
+ test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
+ `<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
+ test("http://localhost:3000/gogits/gogs/issues/4",
+ `<a href="http://localhost:3000/gogits/gogs/issues/4" class="ref-issue">#4</a>`)
+ test("http://localhost:3000/gogits/gogs/issues/4 test",
+ `<a href="http://localhost:3000/gogits/gogs/issues/4" class="ref-issue">#4</a> test`)
+ test("http://localhost:3000/gogits/gogs/issues/4?a=1&b=2#comment-form test",
+ `<a href="http://localhost:3000/gogits/gogs/issues/4?a=1&amp;b=2#comment-form" class="ref-issue">#4</a> test`)
+ test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24",
+ `<a href="http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24" class="ref-issue">testOrg/testOrgRepo#2/files (comment)</a>`)
+ test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/commits",
+ `<a href="http://localhost:3000/testOrg/testOrgRepo/pulls/2/commits" class="ref-issue">testOrg/testOrgRepo#2/commits</a>`)
+}
+
+func TestRegExp_sha1CurrentPattern(t *testing.T) {
+ trueTestCases := []string{
+ "d8a994ef243349f321568f9e36d5c3f444b99cae",
+ "abcdefabcdefabcdefabcdefabcdefabcdefabcd",
+ "(abcdefabcdefabcdefabcdefabcdefabcdefabcd)",
+ "[abcdefabcdefabcdefabcdefabcdefabcdefabcd]",
+ "abcdefabcdefabcdefabcdefabcdefabcdefabcd.",
+ "abcdefabcdefabcdefabcdefabcdefabcdefabcd:",
+ }
+ falseTestCases := []string{
+ "test",
+ "abcdefg",
+ "e59ff077-2d03-4e6b-964d-63fbaea81f",
+ "abcdefghijklmnopqrstuvwxyzabcdefghijklmn",
+ "abcdefghijklmnopqrstuvwxyzabcdefghijklmO",
+ }
+
+ for _, testCase := range trueTestCases {
+ assert.True(t, hashCurrentPattern.MatchString(testCase))
+ }
+ for _, testCase := range falseTestCases {
+ assert.False(t, hashCurrentPattern.MatchString(testCase))
+ }
+}
+
+func TestRegExp_anySHA1Pattern(t *testing.T) {
+ testCases := map[string][]string{
+ "https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": {
+ "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
+ "/test/unit/event.js",
+ "",
+ "#L2703",
+ },
+ "https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": {
+ "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
+ "/test/unit/event.js",
+ "",
+ "",
+ },
+ "https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": {
+ "0705be475092aede1eddae01319ec931fb9c65fc",
+ "",
+ "",
+ "",
+ },
+ "https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": {
+ "0705be475092aede1eddae01319ec931fb9c65fc",
+ "/src",
+ "",
+ "",
+ },
+ "https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": {
+ "d8a994ef243349f321568f9e36d5c3f444b99cae",
+ "",
+ "",
+ "#diff-2",
+ },
+ "https://codeberg.org/forgejo/forgejo/src/commit/949ab9a5c4cac742f84ae5a9fa186f8d6eb2cdc0/RELEASE-NOTES.md?display=source&w=1#L7-L9": {
+ "949ab9a5c4cac742f84ae5a9fa186f8d6eb2cdc0",
+ "/RELEASE-NOTES.md",
+ "?display=source&w=1",
+ "#L7-L9",
+ },
+ }
+
+ for k, v := range testCases {
+ assert.Equal(t, anyHashPattern.FindStringSubmatch(k)[1:], v)
+ }
+}
+
+func TestRegExp_shortLinkPattern(t *testing.T) {
+ trueTestCases := []string{
+ "[[stuff]]",
+ "[[]]",
+ "[[stuff|title=Difficult name with spaces*!]]",
+ }
+ falseTestCases := []string{
+ "test",
+ "abcdefg",
+ "[[]",
+ "[[",
+ "[]",
+ "]]",
+ "abcdefghijklmnopqrstuvwxyz",
+ }
+
+ for _, testCase := range trueTestCases {
+ assert.True(t, shortLinkPattern.MatchString(testCase))
+ }
+ for _, testCase := range falseTestCases {
+ assert.False(t, shortLinkPattern.MatchString(testCase))
+ }
+}
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
new file mode 100644
index 0000000..68d1ada
--- /dev/null
+++ b/modules/markup/html_test.go
@@ -0,0 +1,1029 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup_test
+
+import (
+ "context"
+ "io"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var localMetas = map[string]string{
+ "user": "gogits",
+ "repo": "gogs",
+ "repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/",
+}
+
+func TestMain(m *testing.M) {
+ unittest.InitSettings()
+ if err := git.InitSimple(context.Background()); err != nil {
+ log.Fatal("git init failed, err: %v", err)
+ }
+ os.Exit(m.Run())
+}
+
+func TestRender_Commits(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+ test := func(input, expected string) {
+ buffer, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: ".md",
+ Links: markup.Links{
+ AbsolutePrefix: true,
+ Base: markup.TestRepoURL,
+ },
+ Metas: localMetas,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+ repo := markup.TestRepoURL
+ commit := util.URLJoin(repo, "commit", sha)
+ tree := util.URLJoin(repo, "tree", sha, "src")
+
+ file := util.URLJoin(repo, "commit", sha, "example.txt")
+ fileWithExtra := file + ":"
+ fileWithHash := file + "#L2"
+ fileWithHasExtra := file + "#L2:"
+ commitCompare := util.URLJoin(repo, "compare", sha+"..."+sha)
+ commitCompareWithHash := commitCompare + "#L2"
+
+ test(sha, `<p><a href="`+commit+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
+ test(sha[:7], `<p><a href="`+commit[:len(commit)-(40-7)]+`" rel="nofollow"><code>65f1bf2</code></a></p>`)
+ test(sha[:39], `<p><a href="`+commit[:len(commit)-(40-39)]+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
+ test(commit, `<p><a href="`+commit+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
+ test(tree, `<p><a href="`+tree+`" rel="nofollow"><code>65f1bf27bc/src</code></a></p>`)
+
+ test(file, `<p><a href="`+file+`" rel="nofollow"><code>65f1bf27bc/example.txt</code></a></p>`)
+ test(fileWithExtra, `<p><a href="`+file+`" rel="nofollow"><code>65f1bf27bc/example.txt</code></a>:</p>`)
+ test(fileWithHash, `<p><a href="`+fileWithHash+`" rel="nofollow"><code>65f1bf27bc/example.txt (L2)</code></a></p>`)
+ test(fileWithHasExtra, `<p><a href="`+fileWithHash+`" rel="nofollow"><code>65f1bf27bc/example.txt (L2)</code></a>:</p>`)
+ test(commitCompare, `<p><a href="`+commitCompare+`" rel="nofollow"><code>65f1bf27bc...65f1bf27bc</code></a></p>`)
+ test(commitCompareWithHash, `<p><a href="`+commitCompareWithHash+`" rel="nofollow"><code>65f1bf27bc...65f1bf27bc (L2)</code></a></p>`)
+
+ test("commit "+sha, `<p>commit <a href="`+commit+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
+ test("/home/gitea/"+sha, "<p>/home/gitea/"+sha+"</p>")
+ test("deadbeef", `<p>deadbeef</p>`)
+ test("d27ace93", `<p>d27ace93</p>`)
+ test(sha[:14]+".x", `<p>`+sha[:14]+`.x</p>`)
+
+ expected14 := `<a href="` + commit[:len(commit)-(40-14)] + `" rel="nofollow"><code>` + sha[:10] + `</code></a>`
+ test(sha[:14]+".", `<p>`+expected14+`.</p>`)
+ test(sha[:14]+",", `<p>`+expected14+`,</p>`)
+ test("["+sha[:14]+"]", `<p>[`+expected14+`]</p>`)
+}
+
+func TestRender_CrossReferences(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ test := func(input, expected string) {
+ buffer, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: "a.md",
+ Links: markup.Links{
+ AbsolutePrefix: true,
+ Base: setting.AppSubURL,
+ },
+ Metas: localMetas,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ test(
+ "gogits/gogs#12345",
+ `<p><a href="`+util.URLJoin(markup.TestAppURL, "gogits", "gogs", "issues", "12345")+`" class="ref-issue" rel="nofollow">gogits/gogs#12345</a></p>`)
+ test(
+ "go-gitea/gitea#12345",
+ `<p><a href="`+util.URLJoin(markup.TestAppURL, "go-gitea", "gitea", "issues", "12345")+`" class="ref-issue" rel="nofollow">go-gitea/gitea#12345</a></p>`)
+ test(
+ "/home/gitea/go-gitea/gitea#12345",
+ `<p>/home/gitea/go-gitea/gitea#12345</p>`)
+ test(
+ util.URLJoin(markup.TestAppURL, "gogitea", "gitea", "issues", "12345"),
+ `<p><a href="`+util.URLJoin(markup.TestAppURL, "gogitea", "gitea", "issues", "12345")+`" class="ref-issue" rel="nofollow">gogitea/gitea#12345</a></p>`)
+ test(
+ util.URLJoin(markup.TestAppURL, "go-gitea", "gitea", "issues", "12345"),
+ `<p><a href="`+util.URLJoin(markup.TestAppURL, "go-gitea", "gitea", "issues", "12345")+`" class="ref-issue" rel="nofollow">go-gitea/gitea#12345</a></p>`)
+ test(
+ util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345"),
+ `<p><a href="`+util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345")+`" class="ref-issue" rel="nofollow">gogitea/some-repo-name#12345</a></p>`)
+
+ sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+ urlWithQuery := util.URLJoin(markup.TestAppURL, "forgejo", "some-repo-name", "commit", sha, "README.md") + "?display=source#L1-L5"
+ test(
+ urlWithQuery,
+ `<p><a href="`+urlWithQuery+`" rel="nofollow"><code>`+sha[:10]+`/README.md (L1-L5)</code></a></p>`)
+}
+
+func TestRender_links(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ test := func(input, expected string) {
+ buffer, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: "a.md",
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+ // Text that should be turned into URL
+
+ defaultCustom := setting.Markdown.CustomURLSchemes
+ setting.Markdown.CustomURLSchemes = []string{"ftp", "magnet"}
+ markup.InitializeSanitizer()
+ markup.CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
+
+ test(
+ "https://www.example.com",
+ `<p><a href="https://www.example.com" rel="nofollow">https://www.example.com</a></p>`)
+ test(
+ "http://www.example.com",
+ `<p><a href="http://www.example.com" rel="nofollow">http://www.example.com</a></p>`)
+ test(
+ "https://example.com",
+ `<p><a href="https://example.com" rel="nofollow">https://example.com</a></p>`)
+ test(
+ "http://example.com",
+ `<p><a href="http://example.com" rel="nofollow">http://example.com</a></p>`)
+ test(
+ "http://foo.com/blah_blah",
+ `<p><a href="http://foo.com/blah_blah" rel="nofollow">http://foo.com/blah_blah</a></p>`)
+ test(
+ "http://foo.com/blah_blah/",
+ `<p><a href="http://foo.com/blah_blah/" rel="nofollow">http://foo.com/blah_blah/</a></p>`)
+ test(
+ "http://www.example.com/wpstyle/?p=364",
+ `<p><a href="http://www.example.com/wpstyle/?p=364" rel="nofollow">http://www.example.com/wpstyle/?p=364</a></p>`)
+ test(
+ "https://www.example.com/foo/?bar=baz&inga=42&quux",
+ `<p><a href="https://www.example.com/foo/?bar=baz&amp;inga=42&amp;quux" rel="nofollow">https://www.example.com/foo/?bar=baz&amp;inga=42&amp;quux</a></p>`)
+ test(
+ "http://142.42.1.1/",
+ `<p><a href="http://142.42.1.1/" rel="nofollow">http://142.42.1.1/</a></p>`)
+ test(
+ "https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd",
+ `<p><a href="https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd" rel="nofollow">https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd</a></p>`)
+ test(
+ "https://en.wikipedia.org/wiki/URL_(disambiguation)",
+ `<p><a href="https://en.wikipedia.org/wiki/URL_(disambiguation)" rel="nofollow">https://en.wikipedia.org/wiki/URL_(disambiguation)</a></p>`)
+ test(
+ "https://foo_bar.example.com/",
+ `<p><a href="https://foo_bar.example.com/" rel="nofollow">https://foo_bar.example.com/</a></p>`)
+ test(
+ "https://stackoverflow.com/questions/2896191/what-is-go-used-fore",
+ `<p><a href="https://stackoverflow.com/questions/2896191/what-is-go-used-fore" rel="nofollow">https://stackoverflow.com/questions/2896191/what-is-go-used-fore</a></p>`)
+ test(
+ "https://username:password@gitea.com",
+ `<p><a href="https://username:password@gitea.com" rel="nofollow">https://username:password@gitea.com</a></p>`)
+ test(
+ "ftp://gitea.com/file.txt",
+ `<p><a href="ftp://gitea.com/file.txt" rel="nofollow">ftp://gitea.com/file.txt</a></p>`)
+ test(
+ "magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&dn=download",
+ `<p><a href="magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&amp;dn=download" rel="nofollow">magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&amp;dn=download</a></p>`)
+
+ // Test that should *not* be turned into URL
+ test(
+ "www.example.com",
+ `<p>www.example.com</p>`)
+ test(
+ "example.com",
+ `<p>example.com</p>`)
+ test(
+ "test.example.com",
+ `<p>test.example.com</p>`)
+ test(
+ "http://",
+ `<p>http://</p>`)
+ test(
+ "https://",
+ `<p>https://</p>`)
+ test(
+ "://",
+ `<p>://</p>`)
+ test(
+ "www",
+ `<p>www</p>`)
+ test(
+ "ftps://gitea.com",
+ `<p>ftps://gitea.com</p>`)
+
+ // Restore previous settings
+ setting.Markdown.CustomURLSchemes = defaultCustom
+ markup.InitializeSanitizer()
+ markup.CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
+}
+
+func TestRender_email(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ test := func(input, expected string) {
+ res, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: "a.md",
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
+ }
+ // Text that should be turned into email link
+
+ test(
+ "info@gitea.com",
+ `<p><a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a></p>`)
+ test(
+ "(info@gitea.com)",
+ `<p>(<a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>)</p>`)
+ test(
+ "[info@gitea.com]",
+ `<p>[<a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>]</p>`)
+ test(
+ "info@gitea.com.",
+ `<p><a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>.</p>`)
+ test(
+ "firstname+lastname@gitea.com",
+ `<p><a href="mailto:firstname+lastname@gitea.com" rel="nofollow">firstname+lastname@gitea.com</a></p>`)
+ test(
+ "send email to info@gitea.co.uk.",
+ `<p>send email to <a href="mailto:info@gitea.co.uk" rel="nofollow">info@gitea.co.uk</a>.</p>`)
+
+ test(
+ `j.doe@example.com,
+ j.doe@example.com.
+ j.doe@example.com;
+ j.doe@example.com?
+ j.doe@example.com!`,
+ `<p><a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>,<br/>
+<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>.<br/>
+<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>;<br/>
+<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>?<br/>
+<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>!</p>`)
+
+ // Test that should *not* be turned into email links
+ test(
+ "\"info@gitea.com\"",
+ `<p>&#34;info@gitea.com&#34;</p>`)
+ test(
+ "/home/gitea/mailstore/info@gitea/com",
+ `<p>/home/gitea/mailstore/info@gitea/com</p>`)
+ test(
+ "git@try.gitea.io:go-gitea/gitea.git",
+ `<p>git@try.gitea.io:go-gitea/gitea.git</p>`)
+ test(
+ "gitea@3",
+ `<p>gitea@3</p>`)
+ test(
+ "gitea@gmail.c",
+ `<p>gitea@gmail.c</p>`)
+ test(
+ "email@domain@domain.com",
+ `<p>email@domain@domain.com</p>`)
+ test(
+ "email@domain..com",
+ `<p>email@domain..com</p>`)
+}
+
+func TestRender_emoji(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+ setting.StaticURLPrefix = markup.TestAppURL
+
+ test := func(input, expected string) {
+ expected = strings.ReplaceAll(expected, "&", "&amp;")
+ buffer, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: "a.md",
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ // Make sure we can successfully match every emoji in our dataset with regex
+ for i := range emoji.GemojiData {
+ test(
+ emoji.GemojiData[i].Emoji,
+ `<p><span class="emoji" aria-label="`+emoji.GemojiData[i].Description+`">`+emoji.GemojiData[i].Emoji+`</span></p>`)
+ }
+ for i := range emoji.GemojiData {
+ test(
+ ":"+emoji.GemojiData[i].Aliases[0]+":",
+ `<p><span class="emoji" aria-label="`+emoji.GemojiData[i].Description+`">`+emoji.GemojiData[i].Emoji+`</span></p>`)
+ }
+
+ // Text that should be turned into or recognized as emoji
+ test(
+ ":gitea:",
+ `<p><span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span></p>`)
+ test(
+ ":custom-emoji:",
+ `<p>:custom-emoji:</p>`)
+ setting.UI.CustomEmojisMap["custom-emoji"] = ":custom-emoji:"
+ test(
+ ":custom-emoji:",
+ `<p><span class="emoji" aria-label="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span></p>`)
+ test(
+ "这是字符:1::+1: some🊠\U0001f44d:custom-emoji: :gitea:",
+ `<p>这是字符:1:<span class="emoji" aria-label="thumbs up">ðŸ‘</span> some<span class="emoji" aria-label="crocodile">ðŸŠ</span> `+
+ `<span class="emoji" aria-label="thumbs up">ðŸ‘</span><span class="emoji" aria-label="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span> `+
+ `<span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span></p>`)
+ test(
+ "Some text with 😄 in the middle",
+ `<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle</p>`)
+ test(
+ "Some text with :smile: in the middle",
+ `<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle</p>`)
+ test(
+ "Some text with 😄😄 2 emoji next to each other",
+ `<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span><span class="emoji" aria-label="grinning face with smiling eyes">😄</span> 2 emoji next to each other</p>`)
+ test(
+ "😎🤪ðŸ”🤑â“",
+ `<p><span class="emoji" aria-label="smiling face with sunglasses">😎</span><span class="emoji" aria-label="zany face">🤪</span><span class="emoji" aria-label="locked with key">ðŸ”</span><span class="emoji" aria-label="money-mouth face">🤑</span><span class="emoji" aria-label="red question mark">â“</span></p>`)
+
+ // should match nothing
+ test(
+ "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
+ `<p>2001:0db8:85a3:0000:0000:8a2e:0370:7334</p>`)
+ test(
+ ":not exist:",
+ `<p>:not exist:</p>`)
+}
+
+func TestRender_ShortLinks(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+ tree := util.URLJoin(markup.TestRepoURL, "src", "master")
+
+ test := func(input, expected, expectedWiki string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ BranchPath: "master",
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ buffer, err = markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ },
+ Metas: localMetas,
+ IsWiki: true,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
+ }
+
+ mediatree := util.URLJoin(markup.TestRepoURL, "media", "master")
+ url := util.URLJoin(tree, "Link")
+ otherURL := util.URLJoin(tree, "Other-Link")
+ encodedURL := util.URLJoin(tree, "Link%3F")
+ imgurl := util.URLJoin(mediatree, "Link.jpg")
+ otherImgurl := util.URLJoin(mediatree, "Link+Other.jpg")
+ encodedImgurl := util.URLJoin(mediatree, "Link+%23.jpg")
+ notencodedImgurl := util.URLJoin(mediatree, "some", "path", "Link+#.jpg")
+ urlWiki := util.URLJoin(markup.TestRepoURL, "wiki", "Link")
+ otherURLWiki := util.URLJoin(markup.TestRepoURL, "wiki", "Other-Link")
+ encodedURLWiki := util.URLJoin(markup.TestRepoURL, "wiki", "Link%3F")
+ imgurlWiki := util.URLJoin(markup.TestRepoURL, "wiki", "raw", "Link.jpg")
+ otherImgurlWiki := util.URLJoin(markup.TestRepoURL, "wiki", "raw", "Link+Other.jpg")
+ encodedImgurlWiki := util.URLJoin(markup.TestRepoURL, "wiki", "raw", "Link+%23.jpg")
+ notencodedImgurlWiki := util.URLJoin(markup.TestRepoURL, "wiki", "raw", "some", "path", "Link+#.jpg")
+ favicon := "http://google.com/favicon.ico"
+
+ test(
+ "[[Link]]",
+ `<p><a href="`+url+`" rel="nofollow">Link</a></p>`,
+ `<p><a href="`+urlWiki+`" rel="nofollow">Link</a></p>`)
+ test(
+ "[[Link.jpg]]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Link.jpg" alt="Link.jpg"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="Link.jpg" alt="Link.jpg"/></a></p>`)
+ test(
+ "[["+favicon+"]]",
+ `<p><a href="`+favicon+`" rel="nofollow"><img src="`+favicon+`" title="favicon.ico" alt="`+favicon+`"/></a></p>`,
+ `<p><a href="`+favicon+`" rel="nofollow"><img src="`+favicon+`" title="favicon.ico" alt="`+favicon+`"/></a></p>`)
+ test(
+ "[[Name|Link]]",
+ `<p><a href="`+url+`" rel="nofollow">Name</a></p>`,
+ `<p><a href="`+urlWiki+`" rel="nofollow">Name</a></p>`)
+ test(
+ "[[Name|Link.jpg]]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Name" alt="Name"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="Name" alt="Name"/></a></p>`)
+ test(
+ "[[Name|Link.jpg|alt=AltName]]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="AltName" alt="AltName"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="AltName" alt="AltName"/></a></p>`)
+ test(
+ "[[Name|Link.jpg|title=Title]]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="Title"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="Title" alt="Title"/></a></p>`)
+ test(
+ "[[Name|Link.jpg|alt=AltName|title=Title]]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="AltName"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="Title" alt="AltName"/></a></p>`)
+ test(
+ "[[Name|Link.jpg|alt=\"AltName\"|title='Title']]",
+ `<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="AltName"/></a></p>`,
+ `<p><a href="`+imgurlWiki+`" rel="nofollow"><img src="`+imgurlWiki+`" title="Title" alt="AltName"/></a></p>`)
+ test(
+ "[[Name|Link Other.jpg|alt=\"AltName\"|title='Title']]",
+ `<p><a href="`+otherImgurl+`" rel="nofollow"><img src="`+otherImgurl+`" title="Title" alt="AltName"/></a></p>`,
+ `<p><a href="`+otherImgurlWiki+`" rel="nofollow"><img src="`+otherImgurlWiki+`" title="Title" alt="AltName"/></a></p>`)
+ test(
+ "[[Link]] [[Other Link]]",
+ `<p><a href="`+url+`" rel="nofollow">Link</a> <a href="`+otherURL+`" rel="nofollow">Other Link</a></p>`,
+ `<p><a href="`+urlWiki+`" rel="nofollow">Link</a> <a href="`+otherURLWiki+`" rel="nofollow">Other Link</a></p>`)
+ test(
+ "[[Link?]]",
+ `<p><a href="`+encodedURL+`" rel="nofollow">Link?</a></p>`,
+ `<p><a href="`+encodedURLWiki+`" rel="nofollow">Link?</a></p>`)
+ test(
+ "[[Link]] [[Other Link]] [[Link?]]",
+ `<p><a href="`+url+`" rel="nofollow">Link</a> <a href="`+otherURL+`" rel="nofollow">Other Link</a> <a href="`+encodedURL+`" rel="nofollow">Link?</a></p>`,
+ `<p><a href="`+urlWiki+`" rel="nofollow">Link</a> <a href="`+otherURLWiki+`" rel="nofollow">Other Link</a> <a href="`+encodedURLWiki+`" rel="nofollow">Link?</a></p>`)
+ test(
+ "[[Link #.jpg]]",
+ `<p><a href="`+encodedImgurl+`" rel="nofollow"><img src="`+encodedImgurl+`" title="Link #.jpg" alt="Link #.jpg"/></a></p>`,
+ `<p><a href="`+encodedImgurlWiki+`" rel="nofollow"><img src="`+encodedImgurlWiki+`" title="Link #.jpg" alt="Link #.jpg"/></a></p>`)
+ test(
+ "[[Name|Link #.jpg|alt=\"AltName\"|title='Title']]",
+ `<p><a href="`+encodedImgurl+`" rel="nofollow"><img src="`+encodedImgurl+`" title="Title" alt="AltName"/></a></p>`,
+ `<p><a href="`+encodedImgurlWiki+`" rel="nofollow"><img src="`+encodedImgurlWiki+`" title="Title" alt="AltName"/></a></p>`)
+ test(
+ "[[some/path/Link #.jpg]]",
+ `<p><a href="`+notencodedImgurl+`" rel="nofollow"><img src="`+notencodedImgurl+`" title="Link #.jpg" alt="some/path/Link #.jpg"/></a></p>`,
+ `<p><a href="`+notencodedImgurlWiki+`" rel="nofollow"><img src="`+notencodedImgurlWiki+`" title="Link #.jpg" alt="some/path/Link #.jpg"/></a></p>`)
+ test(
+ "<p><a href=\"https://example.org\">[[foobar]]</a></p>",
+ `<p><a href="https://example.org" rel="nofollow">[[foobar]]</a></p>`,
+ `<p><a href="https://example.org" rel="nofollow">[[foobar]]</a></p>`)
+}
+
+func TestRender_RelativeImages(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ test := func(input, expected, expectedWiki string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ BranchPath: "master",
+ },
+ Metas: localMetas,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ buffer, err = markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: markup.TestRepoURL,
+ },
+ Metas: localMetas,
+ IsWiki: true,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
+ }
+
+ rawwiki := util.URLJoin(markup.TestRepoURL, "wiki", "raw")
+ mediatree := util.URLJoin(markup.TestRepoURL, "media", "master")
+
+ test(
+ `<img src="Link">`,
+ `<img src="`+util.URLJoin(mediatree, "Link")+`"/>`,
+ `<img src="`+util.URLJoin(rawwiki, "Link")+`"/>`)
+
+ test(
+ `<img src="./icon.png">`,
+ `<img src="`+util.URLJoin(mediatree, "icon.png")+`"/>`,
+ `<img src="`+util.URLJoin(rawwiki, "icon.png")+`"/>`)
+}
+
+func Test_ParseClusterFuzz(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ localMetas := map[string]string{
+ "user": "go-gitea",
+ "repo": "gitea",
+ }
+
+ data := "<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
+
+ var res strings.Builder
+ err := markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: "https://example.com",
+ },
+ Metas: localMetas,
+ }, strings.NewReader(data), &res)
+ require.NoError(t, err)
+ assert.NotContains(t, res.String(), "<html")
+
+ data = "<!DOCTYPE html>\n<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
+
+ res.Reset()
+ err = markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: "https://example.com",
+ },
+ Metas: localMetas,
+ }, strings.NewReader(data), &res)
+
+ require.NoError(t, err)
+ assert.NotContains(t, res.String(), "<html")
+}
+
+func TestPostProcess_RenderDocument(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+ setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
+
+ localMetas := map[string]string{
+ "user": "go-gitea",
+ "repo": "gitea",
+ "mode": "document",
+ }
+
+ test := func(input, expected string) {
+ var res strings.Builder
+ err := markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ AbsolutePrefix: true,
+ Base: "https://example.com",
+ },
+ Metas: localMetas,
+ }, strings.NewReader(input), &res)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
+ }
+
+ // Issue index shouldn't be post processing in a document.
+ test(
+ "#1",
+ "#1")
+
+ // But cross-referenced issue index should work.
+ test(
+ "go-gitea/gitea#12345",
+ `<a href="`+util.URLJoin(markup.TestAppURL, "go-gitea", "gitea", "issues", "12345")+`" class="ref-issue">go-gitea/gitea#12345</a>`)
+
+ // Test that other post processing still works.
+ test(
+ ":gitea:",
+ `<span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span>`)
+ test(
+ "Some text with 😄 in the middle",
+ `Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle`)
+ test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
+ `<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
+}
+
+func TestIssue16020(t *testing.T) {
+ setting.AppURL = markup.TestAppURL
+
+ localMetas := map[string]string{
+ "user": "go-gitea",
+ "repo": "gitea",
+ }
+
+ data := `<img src="data:image/png;base64,i//V"/>`
+
+ var res strings.Builder
+ err := markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: localMetas,
+ }, strings.NewReader(data), &res)
+ require.NoError(t, err)
+ assert.Equal(t, data, res.String())
+}
+
+func BenchmarkEmojiPostprocess(b *testing.B) {
+ data := "🥰 "
+ for len(data) < 1<<16 {
+ data += data
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var res strings.Builder
+ err := markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: localMetas,
+ }, strings.NewReader(data), &res)
+ require.NoError(b, err)
+ }
+}
+
+func TestFuzz(t *testing.T) {
+ s := "t/l/issues/8#/../../a"
+ renderContext := markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: "https://example.com/go-gitea/gitea",
+ },
+ Metas: map[string]string{
+ "user": "go-gitea",
+ "repo": "gitea",
+ },
+ }
+
+ err := markup.PostProcess(&renderContext, strings.NewReader(s), io.Discard)
+
+ require.NoError(t, err)
+}
+
+func TestIssue18471(t *testing.T) {
+ data := `http://domain/org/repo/compare/783b039...da951ce`
+
+ var res strings.Builder
+ err := markup.PostProcess(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Metas: localMetas,
+ }, strings.NewReader(data), &res)
+
+ require.NoError(t, err)
+ assert.Equal(t, "<a href=\"http://domain/org/repo/compare/783b039...da951ce\" class=\"compare\"><code class=\"nohighlight\">783b039...da951ce</code></a>", res.String())
+}
+
+func TestRender_FilePreview(t *testing.T) {
+ defer test.MockVariableValue(&setting.StaticRootPath, "../../")()
+ defer test.MockVariableValue(&setting.Names, []string{"english"})()
+ defer test.MockVariableValue(&setting.Langs, []string{"en-US"})()
+ translation.InitLocales(context.Background())
+
+ setting.AppURL = markup.TestAppURL
+ markup.Init(&markup.ProcessorHelper{
+ GetRepoFileBlob: func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error) {
+ gitRepo, err := git.OpenRepository(git.DefaultContext, "./tests/repo/repo1_filepreview")
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetCommit(commitSha)
+ require.NoError(t, err)
+
+ blob, err := commit.GetBlobByPath(filePath)
+ require.NoError(t, err)
+
+ return blob, nil
+ },
+ })
+
+ sha := "190d9492934af498c3f669d6a2431dc5459e5b20"
+ commitFilePreview := util.URLJoin(markup.TestRepoURL, "src", "commit", sha, "path", "to", "file.go") + "#L2-L3"
+
+ testRender := func(input, expected string, metas map[string]string) {
+ buffer, err := markup.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ RelativePath: ".md",
+ Metas: metas,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ t.Run("single", func(t *testing.T) {
+ testRender(
+ commitFilePreview,
+ `<p></p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ localMetas,
+ )
+ })
+
+ t.Run("cross-repo", func(t *testing.T) {
+ testRender(
+ commitFilePreview,
+ `<p></p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/" rel="nofollow">gogits/gogs</a> – `+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">gogits/gogs@190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ map[string]string{
+ "user": "gogits",
+ "repo": "gogs2",
+ },
+ )
+ })
+ t.Run("single-line", func(t *testing.T) {
+ testRender(
+ util.URLJoin(markup.TestRepoURL, "src", "commit", "4c1aaf56bcb9f39dcf65f3f250726850aed13cd6", "single-line.txt")+"#L1",
+ `<p></p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/" rel="nofollow">gogits/gogs</a> – `+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/4c1aaf56bcb9f39dcf65f3f250726850aed13cd6/single-line.txt#L1" class="muted" rel="nofollow">single-line.txt</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Line 1 in <a href="http://localhost:3000/gogits/gogs/src/commit/4c1aaf56bcb9f39dcf65f3f250726850aed13cd6" class="text black" rel="nofollow">gogits/gogs@4c1aaf5</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="1"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner">A`+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ map[string]string{
+ "user": "gogits",
+ "repo": "gogs2",
+ },
+ )
+ })
+
+ t.Run("AppSubURL", func(t *testing.T) {
+ urlWithSub := util.URLJoin(markup.TestAppURL, "sub", markup.TestOrgRepo, "src", "commit", sha, "path", "to", "file.go") + "#L2-L3"
+
+ testRender(
+ urlWithSub,
+ `<p><a href="http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" rel="nofollow"><code>190d949293/path/to/file.go (L2-L3)</code></a></p>`,
+ localMetas,
+ )
+
+ defer test.MockVariableValue(&setting.AppURL, markup.TestAppURL+"sub/")()
+ defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+
+ testRender(
+ urlWithSub,
+ `<p></p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ localMetas,
+ )
+
+ testRender(
+ "first without sub "+commitFilePreview+" second "+urlWithSub,
+ `<p>first without sub <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" rel="nofollow"><code>190d949293/path/to/file.go (L2-L3)</code></a> second </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ localMetas,
+ )
+ })
+
+ t.Run("multiples", func(t *testing.T) {
+ testRender(
+ "first "+commitFilePreview+" second "+commitFilePreview,
+ `<p>first </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p> second </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ localMetas,
+ )
+
+ testRender(
+ "first "+commitFilePreview+" second "+commitFilePreview+" third "+commitFilePreview,
+ `<p>first </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p> second </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p> third </p>`+
+ `<div class="file-preview-box">`+
+ `<div class="header">`+
+ `<div>`+
+ `<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
+ `</div>`+
+ `<span class="text small grey">`+
+ `Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
+ `</span>`+
+ `</div>`+
+ `<div class="ui table">`+
+ `<table class="file-preview">`+
+ `<tbody>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="2"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `<tr>`+
+ `<td class="lines-num"><span data-line-number="3"></span></td>`+
+ `<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
+ `</tr>`+
+ `</tbody>`+
+ `</table>`+
+ `</div>`+
+ `</div>`+
+ `<p></p>`,
+ localMetas,
+ )
+ })
+}
diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go
new file mode 100644
index 0000000..7f0ac6a
--- /dev/null
+++ b/modules/markup/markdown/ast.go
@@ -0,0 +1,176 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "strconv"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+// Details is a block that contains Summary and details
+type Details struct {
+ ast.BaseBlock
+}
+
+// Dump implements Node.Dump .
+func (n *Details) Dump(source []byte, level int) {
+ ast.DumpHelper(n, source, level, nil, nil)
+}
+
+// KindDetails is the NodeKind for Details
+var KindDetails = ast.NewNodeKind("Details")
+
+// Kind implements Node.Kind.
+func (n *Details) Kind() ast.NodeKind {
+ return KindDetails
+}
+
+// NewDetails returns a new Paragraph node.
+func NewDetails() *Details {
+ return &Details{
+ BaseBlock: ast.BaseBlock{},
+ }
+}
+
+// IsDetails returns true if the given node implements the Details interface,
+// otherwise false.
+func IsDetails(node ast.Node) bool {
+ _, ok := node.(*Details)
+ return ok
+}
+
+// Summary is a block that contains the summary of details block
+type Summary struct {
+ ast.BaseBlock
+}
+
+// Dump implements Node.Dump .
+func (n *Summary) Dump(source []byte, level int) {
+ ast.DumpHelper(n, source, level, nil, nil)
+}
+
+// KindSummary is the NodeKind for Summary
+var KindSummary = ast.NewNodeKind("Summary")
+
+// Kind implements Node.Kind.
+func (n *Summary) Kind() ast.NodeKind {
+ return KindSummary
+}
+
+// NewSummary returns a new Summary node.
+func NewSummary() *Summary {
+ return &Summary{
+ BaseBlock: ast.BaseBlock{},
+ }
+}
+
+// IsSummary returns true if the given node implements the Summary interface,
+// otherwise false.
+func IsSummary(node ast.Node) bool {
+ _, ok := node.(*Summary)
+ return ok
+}
+
+// TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
+type TaskCheckBoxListItem struct {
+ *ast.ListItem
+ IsChecked bool
+ SourcePosition int
+}
+
+// KindTaskCheckBoxListItem is the NodeKind for TaskCheckBoxListItem
+var KindTaskCheckBoxListItem = ast.NewNodeKind("TaskCheckBoxListItem")
+
+// Dump implements Node.Dump .
+func (n *TaskCheckBoxListItem) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["IsChecked"] = strconv.FormatBool(n.IsChecked)
+ m["SourcePosition"] = strconv.FormatInt(int64(n.SourcePosition), 10)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// Kind implements Node.Kind.
+func (n *TaskCheckBoxListItem) Kind() ast.NodeKind {
+ return KindTaskCheckBoxListItem
+}
+
+// NewTaskCheckBoxListItem returns a new TaskCheckBoxListItem node.
+func NewTaskCheckBoxListItem(listItem *ast.ListItem) *TaskCheckBoxListItem {
+ return &TaskCheckBoxListItem{
+ ListItem: listItem,
+ }
+}
+
+// IsTaskCheckBoxListItem returns true if the given node implements the TaskCheckBoxListItem interface,
+// otherwise false.
+func IsTaskCheckBoxListItem(node ast.Node) bool {
+ _, ok := node.(*TaskCheckBoxListItem)
+ return ok
+}
+
+// Icon is an inline for a fomantic icon
+type Icon struct {
+ ast.BaseInline
+ Name []byte
+}
+
+// Dump implements Node.Dump .
+func (n *Icon) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Name"] = string(n.Name)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindIcon is the NodeKind for Icon
+var KindIcon = ast.NewNodeKind("Icon")
+
+// Kind implements Node.Kind.
+func (n *Icon) Kind() ast.NodeKind {
+ return KindIcon
+}
+
+// NewIcon returns a new Paragraph node.
+func NewIcon(name string) *Icon {
+ return &Icon{
+ BaseInline: ast.BaseInline{},
+ Name: []byte(name),
+ }
+}
+
+// IsIcon returns true if the given node implements the Icon interface,
+// otherwise false.
+func IsIcon(node ast.Node) bool {
+ _, ok := node.(*Icon)
+ return ok
+}
+
+// ColorPreview is an inline for a color preview
+type ColorPreview struct {
+ ast.BaseInline
+ Color []byte
+}
+
+// Dump implements Node.Dump.
+func (n *ColorPreview) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["Color"] = string(n.Color)
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindColorPreview is the NodeKind for ColorPreview
+var KindColorPreview = ast.NewNodeKind("ColorPreview")
+
+// Kind implements Node.Kind.
+func (n *ColorPreview) Kind() ast.NodeKind {
+ return KindColorPreview
+}
+
+// NewColorPreview returns a new Span node.
+func NewColorPreview(color []byte) *ColorPreview {
+ return &ColorPreview{
+ BaseInline: ast.BaseInline{},
+ Color: color,
+ }
+}
diff --git a/modules/markup/markdown/callout/ast.go b/modules/markup/markdown/callout/ast.go
new file mode 100644
index 0000000..a5b1bbc
--- /dev/null
+++ b/modules/markup/markdown/callout/ast.go
@@ -0,0 +1,37 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package callout
+
+import (
+ "github.com/yuin/goldmark/ast"
+)
+
+// Attention is an inline for an attention
+type Attention struct {
+ ast.BaseInline
+ AttentionType string
+}
+
+// Dump implements Node.Dump.
+func (n *Attention) Dump(source []byte, level int) {
+ m := map[string]string{}
+ m["AttentionType"] = n.AttentionType
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// KindAttention is the NodeKind for Attention
+var KindAttention = ast.NewNodeKind("Attention")
+
+// Kind implements Node.Kind.
+func (n *Attention) Kind() ast.NodeKind {
+ return KindAttention
+}
+
+// NewAttention returns a new Attention node.
+func NewAttention(attentionType string) *Attention {
+ return &Attention{
+ BaseInline: ast.BaseInline{},
+ AttentionType: attentionType,
+ }
+}
diff --git a/modules/markup/markdown/callout/github.go b/modules/markup/markdown/callout/github.go
new file mode 100644
index 0000000..9b8b611
--- /dev/null
+++ b/modules/markup/markdown/callout/github.go
@@ -0,0 +1,141 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package callout
+
+import (
+ "strings"
+
+ "code.gitea.io/gitea/modules/svg"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+type GitHubCalloutTransformer struct{}
+
+// Transform transforms the given AST tree.
+func (g *GitHubCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
+ supportedAttentionTypes := map[string]bool{
+ "note": true,
+ "tip": true,
+ "important": true,
+ "warning": true,
+ "caution": true,
+ }
+
+ _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+
+ if v, ok := n.(*ast.Blockquote); ok {
+ if v.ChildCount() == 0 {
+ return ast.WalkContinue, nil
+ }
+
+ // We only want attention blockquotes when the AST looks like:
+ // Text: "["
+ // Text: "!TYPE"
+ // Text(SoftLineBreak): "]"
+
+ // grab these nodes and make sure we adhere to the attention blockquote structure
+ firstParagraph := v.FirstChild()
+ if firstParagraph.ChildCount() < 3 {
+ return ast.WalkContinue, nil
+ }
+ firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text)
+ if !ok || string(firstTextNode.Text(reader.Source())) != "[" {
+ return ast.WalkContinue, nil
+ }
+ secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text)
+ if !ok {
+ return ast.WalkContinue, nil
+ }
+ // If the second node's text isn't one of the supported attention
+ // types, continue walking.
+ secondTextNodeText := secondTextNode.Text(reader.Source())
+ attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNodeText), "!"))
+ if _, has := supportedAttentionTypes[attentionType]; !has {
+ return ast.WalkContinue, nil
+ }
+
+ thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text)
+ if !ok || string(thirdTextNode.Text(reader.Source())) != "]" {
+ return ast.WalkContinue, nil
+ }
+
+ // color the blockquote
+ v.SetAttributeString("class", []byte("attention-header attention-"+attentionType))
+
+ // create an emphasis to make it bold
+ attentionParagraph := ast.NewParagraph()
+ attentionParagraph.SetAttributeString("class", []byte("attention-title"))
+ emphasis := ast.NewEmphasis(2)
+ emphasis.SetAttributeString("class", []byte("attention-"+attentionType))
+ firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis)
+
+ // capitalize first letter
+ attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:]))
+
+ // replace the ![TYPE] with a dedicated paragraph of icon+Type
+ emphasis.AppendChild(emphasis, attentionText)
+ attentionParagraph.AppendChild(attentionParagraph, NewAttention(attentionType))
+ attentionParagraph.AppendChild(attentionParagraph, emphasis)
+ firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph)
+ firstParagraph.RemoveChild(firstParagraph, firstTextNode)
+ firstParagraph.RemoveChild(firstParagraph, secondTextNode)
+ firstParagraph.RemoveChild(firstParagraph, thirdTextNode)
+ }
+ return ast.WalkContinue, nil
+ })
+}
+
+type GitHubCalloutHTMLRenderer struct {
+ html.Config
+}
+
+// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+func (r *GitHubCalloutHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindAttention, r.renderAttention)
+}
+
+// renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg
+func (r *GitHubCalloutHTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ n := node.(*Attention)
+
+ var octiconName string
+ switch n.AttentionType {
+ case "note":
+ octiconName = "info"
+ case "tip":
+ octiconName = "light-bulb"
+ case "important":
+ octiconName = "report"
+ case "warning":
+ octiconName = "alert"
+ case "caution":
+ octiconName = "stop"
+ default:
+ octiconName = "info"
+ }
+ _, _ = w.WriteString(string(svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType)))
+ }
+ return ast.WalkContinue, nil
+}
+
+func NewGitHubCalloutHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &GitHubCalloutHTMLRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
diff --git a/modules/markup/markdown/callout/github_legacy.go b/modules/markup/markdown/callout/github_legacy.go
new file mode 100644
index 0000000..32a278b
--- /dev/null
+++ b/modules/markup/markdown/callout/github_legacy.go
@@ -0,0 +1,70 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package callout
+
+import (
+ "strings"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+)
+
+// Transformer for GitHub's legacy callout markup.
+type GitHubLegacyCalloutTransformer struct{}
+
+func (g *GitHubLegacyCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
+ supportedCalloutTypes := map[string]bool{"Note": true, "Warning": true}
+
+ _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+
+ if v, ok := n.(*ast.Blockquote); ok {
+ if v.ChildCount() == 0 {
+ return ast.WalkContinue, nil
+ }
+
+ // The first paragraph contains the callout type.
+ firstParagraph := v.FirstChild()
+ if firstParagraph.ChildCount() < 1 {
+ return ast.WalkContinue, nil
+ }
+
+ // In the legacy GitHub callout markup, the first node of the first
+ // paragraph should be an emphasis.
+ calloutNode, ok := firstParagraph.FirstChild().(*ast.Emphasis)
+ if !ok {
+ return ast.WalkContinue, nil
+ }
+ calloutText := string(calloutNode.Text(reader.Source()))
+ calloutType := strings.ToLower(calloutText)
+ // We only support "Note" and "Warning" callouts in legacy mode,
+ // match only those.
+ if _, has := supportedCalloutTypes[calloutText]; !has {
+ return ast.WalkContinue, nil
+ }
+
+ // Set the attention attribute on the emphasis
+ calloutNode.SetAttributeString("class", []byte("attention-"+calloutType))
+
+ // color the blockquote
+ v.SetAttributeString("class", []byte("attention-header attention-"+calloutType))
+
+ // Create new paragraph.
+ attentionParagraph := ast.NewParagraph()
+ attentionParagraph.SetAttributeString("class", []byte("attention-title"))
+
+ // Move the callout node to the paragraph and insert the paragraph.
+ attentionParagraph.AppendChild(attentionParagraph, NewAttention(calloutType))
+ attentionParagraph.AppendChild(attentionParagraph, calloutNode)
+ firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph)
+ firstParagraph.RemoveChild(firstParagraph, calloutNode)
+ }
+
+ return ast.WalkContinue, nil
+ })
+}
diff --git a/modules/markup/markdown/color_util.go b/modules/markup/markdown/color_util.go
new file mode 100644
index 0000000..355fef3
--- /dev/null
+++ b/modules/markup/markdown/color_util.go
@@ -0,0 +1,19 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import "regexp"
+
+var (
+ hexRGB = regexp.MustCompile(`^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$`)
+ hsl = regexp.MustCompile(`^hsl\([ ]*([012]?[0-9]{1,2}|3[0-5][0-9]|360),[ ]*([0-9]{0,2}|100)\%,[ ]*([0-9]{0,2}|100)\%\)$`)
+ hsla = regexp.MustCompile(`^hsla\(([ ]*[012]?[0-9]{1,2}|3[0-5][0-9]|360),[ ]*([0-9]{0,2}|100)\%,[ ]*([0-9]{0,2}|100)\%,[ ]*(1|1\.0|0|(0\.[0-9]+))\)$`)
+ rgb = regexp.MustCompile(`^rgb\(([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))),){2}([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))))\)$`)
+ rgba = regexp.MustCompile(`^rgba\(([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))),){3}[ ]*(1(\.0)?|0|(0\.[0-9]+))\)$`)
+)
+
+// matchColor return if color is in the form of hex RGB, HSL(A) or RGB(A).
+func matchColor(color string) bool {
+ return hexRGB.MatchString(color) || rgb.MatchString(color) || rgba.MatchString(color) || hsl.MatchString(color) || hsla.MatchString(color)
+}
diff --git a/modules/markup/markdown/color_util_test.go b/modules/markup/markdown/color_util_test.go
new file mode 100644
index 0000000..c6e0555
--- /dev/null
+++ b/modules/markup/markdown/color_util_test.go
@@ -0,0 +1,50 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMatchColor(t *testing.T) {
+ testCases := []struct {
+ input string
+ expected bool
+ }{
+ {"#ddeeffa0", true},
+ {"#ddeefe", true},
+ {"#abcdef", true},
+ {"#abcdeg", false},
+ {"#abcdefg0", false},
+ {"black", false},
+ {"violet", false},
+ {"rgb(255, 255, 255)", true},
+ {"rgb(0, 0, 0)", true},
+ {"rgb(256, 0, 0)", false},
+ {"rgb(0, 256, 0)", false},
+ {"rgb(0, 0, 256)", false},
+ {"rgb(0, 0, 0, 1)", false},
+ {"rgba(0, 0, 0)", false},
+ {"rgba(0, 255, 0, 1)", true},
+ {"rgba(32, 255, 12, 0.55)", true},
+ {"rgba(32, 256, 12, 0.55)", false},
+ {"hsl(0, 0%, 0%)", true},
+ {"hsl(360, 100%, 100%)", true},
+ {"hsl(361, 100%, 50%)", false},
+ {"hsl(360, 101%, 50%)", false},
+ {"hsl(360, 100%, 101%)", false},
+ {"hsl(0, 0%, 0%, 0)", false},
+ {"hsla(0, 0%, 0%)", false},
+ {"hsla(0, 0%, 0%, 0)", true},
+ {"hsla(0, 0%, 0%, 1)", true},
+ {"hsla(0, 0%, 0%, 0.5)", true},
+ {"hsla(0, 0%, 0%, 1.5)", false},
+ }
+ for _, testCase := range testCases {
+ actual := matchColor(testCase.input)
+ assert.Equal(t, testCase.expected, actual)
+ }
+}
diff --git a/modules/markup/markdown/convertyaml.go b/modules/markup/markdown/convertyaml.go
new file mode 100644
index 0000000..1675b68
--- /dev/null
+++ b/modules/markup/markdown/convertyaml.go
@@ -0,0 +1,83 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "github.com/yuin/goldmark/ast"
+ east "github.com/yuin/goldmark/extension/ast"
+ "gopkg.in/yaml.v3"
+)
+
+func nodeToTable(meta *yaml.Node) ast.Node {
+ for {
+ if meta == nil {
+ return nil
+ }
+ switch meta.Kind {
+ case yaml.DocumentNode:
+ meta = meta.Content[0]
+ continue
+ default:
+ }
+ break
+ }
+ switch meta.Kind {
+ case yaml.MappingNode:
+ return mappingNodeToTable(meta)
+ case yaml.SequenceNode:
+ return sequenceNodeToTable(meta)
+ default:
+ return ast.NewString([]byte(meta.Value))
+ }
+}
+
+func mappingNodeToTable(meta *yaml.Node) ast.Node {
+ table := east.NewTable()
+ alignments := make([]east.Alignment, 0, len(meta.Content)/2)
+ for i := 0; i < len(meta.Content); i += 2 {
+ alignments = append(alignments, east.AlignNone)
+ }
+
+ headerRow := east.NewTableRow(alignments)
+ valueRow := east.NewTableRow(alignments)
+ for i := 0; i < len(meta.Content); i += 2 {
+ cell := east.NewTableCell()
+
+ cell.AppendChild(cell, nodeToTable(meta.Content[i]))
+ headerRow.AppendChild(headerRow, cell)
+
+ if i+1 < len(meta.Content) {
+ cell = east.NewTableCell()
+ cell.AppendChild(cell, nodeToTable(meta.Content[i+1]))
+ valueRow.AppendChild(valueRow, cell)
+ }
+ }
+
+ table.AppendChild(table, east.NewTableHeader(headerRow))
+ table.AppendChild(table, valueRow)
+ return table
+}
+
+func sequenceNodeToTable(meta *yaml.Node) ast.Node {
+ table := east.NewTable()
+ alignments := []east.Alignment{east.AlignNone}
+ for _, item := range meta.Content {
+ row := east.NewTableRow(alignments)
+ cell := east.NewTableCell()
+ cell.AppendChild(cell, nodeToTable(item))
+ row.AppendChild(row, cell)
+ table.AppendChild(table, row)
+ }
+ return table
+}
+
+func nodeToDetails(meta *yaml.Node, icon string) ast.Node {
+ details := NewDetails()
+ summary := NewSummary()
+ summary.AppendChild(summary, NewIcon(icon))
+ details.AppendChild(details, summary)
+ details.AppendChild(details, nodeToTable(meta))
+
+ return details
+}
diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go
new file mode 100644
index 0000000..0290e13
--- /dev/null
+++ b/modules/markup/markdown/goldmark.go
@@ -0,0 +1,213 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/yuin/goldmark/ast"
+ east "github.com/yuin/goldmark/extension/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+var byteMailto = []byte("mailto:")
+
+// ASTTransformer is a default transformer of the goldmark tree.
+type ASTTransformer struct{}
+
+func (g *ASTTransformer) applyElementDir(n ast.Node) {
+ if markup.DefaultProcessorHelper.ElementDir != "" {
+ n.SetAttributeString("dir", []byte(markup.DefaultProcessorHelper.ElementDir))
+ }
+}
+
+// Transform transforms the given AST tree.
+func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
+ firstChild := node.FirstChild()
+ tocMode := ""
+ ctx := pc.Get(renderContextKey).(*markup.RenderContext)
+ rc := pc.Get(renderConfigKey).(*RenderConfig)
+
+ tocList := make([]markup.Header, 0, 20)
+ if rc.yamlNode != nil {
+ metaNode := rc.toMetaNode()
+ if metaNode != nil {
+ node.InsertBefore(node, firstChild, metaNode)
+ }
+ tocMode = rc.TOC
+ }
+
+ _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+
+ switch v := n.(type) {
+ case *ast.Heading:
+ g.transformHeading(ctx, v, reader, &tocList)
+ case *ast.Paragraph:
+ g.applyElementDir(v)
+ case *ast.Image:
+ g.transformImage(ctx, v)
+ case *ast.Link:
+ g.transformLink(ctx, v)
+ case *ast.List:
+ g.transformList(ctx, v, rc)
+ case *ast.Text:
+ if v.SoftLineBreak() && !v.HardLineBreak() {
+ if ctx.Metas["mode"] != "document" {
+ v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments)
+ } else {
+ v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)
+ }
+ }
+ case *ast.CodeSpan:
+ g.transformCodeSpan(ctx, v, reader)
+ }
+ return ast.WalkContinue, nil
+ })
+
+ showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main"
+ showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar
+ if len(tocList) > 0 && (showTocInMain || showTocInSidebar) {
+ if showTocInMain {
+ tocNode := createTOCNode(tocList, rc.Lang, nil)
+ node.InsertBefore(node, firstChild, tocNode)
+ } else {
+ tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"})
+ ctx.SidebarTocNode = tocNode
+ }
+ }
+
+ if len(rc.Lang) > 0 {
+ node.SetAttributeString("lang", []byte(rc.Lang))
+ }
+}
+
+// NewHTMLRenderer creates a HTMLRenderer to render
+// in the gitea form.
+func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &HTMLRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
+
+// HTMLRenderer is a renderer.NodeRenderer implementation that
+// renders gitea specific features.
+type HTMLRenderer struct {
+ html.Config
+}
+
+// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindDocument, r.renderDocument)
+ reg.Register(KindDetails, r.renderDetails)
+ reg.Register(KindSummary, r.renderSummary)
+ reg.Register(KindIcon, r.renderIcon)
+ reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
+ reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
+ reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
+}
+
+func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*ast.Document)
+
+ if val, has := n.AttributeString("lang"); has {
+ var err error
+ if entering {
+ _, err = w.WriteString("<div")
+ if err == nil {
+ _, err = w.WriteString(fmt.Sprintf(` lang=%q`, val))
+ }
+ if err == nil {
+ _, err = w.WriteRune('>')
+ }
+ } else {
+ _, err = w.WriteString("</div>")
+ }
+
+ if err != nil {
+ return ast.WalkStop, err
+ }
+ }
+
+ return ast.WalkContinue, nil
+}
+
+func (r *HTMLRenderer) renderDetails(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ var err error
+ if entering {
+ if _, err = w.WriteString("<details"); err != nil {
+ return ast.WalkStop, err
+ }
+ html.RenderAttributes(w, node, nil)
+ _, err = w.WriteString(">")
+ } else {
+ _, err = w.WriteString("</details>")
+ }
+
+ if err != nil {
+ return ast.WalkStop, err
+ }
+
+ return ast.WalkContinue, nil
+}
+
+func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ var err error
+ if entering {
+ _, err = w.WriteString("<summary>")
+ } else {
+ _, err = w.WriteString("</summary>")
+ }
+
+ if err != nil {
+ return ast.WalkStop, err
+ }
+
+ return ast.WalkContinue, nil
+}
+
+var validNameRE = regexp.MustCompile("^[a-z ]+$")
+
+func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+
+ n := node.(*Icon)
+
+ name := strings.TrimSpace(strings.ToLower(string(n.Name)))
+
+ if len(name) == 0 {
+ // skip this
+ return ast.WalkContinue, nil
+ }
+
+ if !validNameRE.MatchString(name) {
+ // skip this
+ return ast.WalkContinue, nil
+ }
+
+ var err error
+ _, err = w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name))
+ if err != nil {
+ return ast.WalkStop, err
+ }
+
+ return ast.WalkContinue, nil
+}
diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go
new file mode 100644
index 0000000..d249d25
--- /dev/null
+++ b/modules/markup/markdown/markdown.go
@@ -0,0 +1,303 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/common"
+ "code.gitea.io/gitea/modules/markup/markdown/callout"
+ "code.gitea.io/gitea/modules/markup/markdown/math"
+ "code.gitea.io/gitea/modules/setting"
+ giteautil "code.gitea.io/gitea/modules/util"
+
+ chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/yuin/goldmark"
+ highlighting "github.com/yuin/goldmark-highlighting/v2"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+var (
+ specMarkdown goldmark.Markdown
+ specMarkdownOnce sync.Once
+)
+
+var (
+ renderContextKey = parser.NewContextKey()
+ renderConfigKey = parser.NewContextKey()
+)
+
+type limitWriter struct {
+ w io.Writer
+ sum int64
+ limit int64
+}
+
+// Write implements the standard Write interface:
+func (l *limitWriter) Write(data []byte) (int, error) {
+ leftToWrite := l.limit - l.sum
+ if leftToWrite < int64(len(data)) {
+ n, err := l.w.Write(data[:leftToWrite])
+ l.sum += int64(n)
+ if err != nil {
+ return n, err
+ }
+ return n, fmt.Errorf("rendered content too large - truncating render")
+ }
+ n, err := l.w.Write(data)
+ l.sum += int64(n)
+ return n, err
+}
+
+// newParserContext creates a parser.Context with the render context set
+func newParserContext(ctx *markup.RenderContext) parser.Context {
+ pc := parser.NewContext(parser.WithIDs(newPrefixedIDs()))
+ pc.Set(renderContextKey, ctx)
+ return pc
+}
+
+// SpecializedMarkdown sets up the Gitea specific markdown extensions
+func SpecializedMarkdown() goldmark.Markdown {
+ specMarkdownOnce.Do(func() {
+ specMarkdown = goldmark.New(
+ goldmark.WithExtensions(
+ extension.NewTable(
+ extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
+ extension.Strikethrough,
+ extension.TaskList,
+ extension.DefinitionList,
+ common.FootnoteExtension,
+ highlighting.NewHighlighting(
+ highlighting.WithFormatOptions(
+ chromahtml.WithClasses(true),
+ chromahtml.PreventSurroundingPre(true),
+ ),
+ highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
+ if entering {
+ language, _ := c.Language()
+ if language == nil {
+ language = []byte("text")
+ }
+
+ languageStr := string(language)
+
+ preClasses := []string{"code-block"}
+ if languageStr == "mermaid" || languageStr == "math" {
+ preClasses = append(preClasses, "is-loading")
+ }
+
+ _, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
+ if err != nil {
+ return
+ }
+
+ // include language-x class as part of commonmark spec
+ // the "display" class is used by "js/markup/math.js" to render the code element as a block
+ _, err = w.WriteString(`<code class="chroma language-` + string(language) + ` display">`)
+ if err != nil {
+ return
+ }
+ } else {
+ _, err := w.WriteString("</code></pre>")
+ if err != nil {
+ return
+ }
+ }
+ }),
+ ),
+ math.NewExtension(
+ math.Enabled(setting.Markdown.EnableMath),
+ ),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAttribute(),
+ parser.WithAutoHeadingID(),
+ parser.WithASTTransformers(
+ util.Prioritized(&callout.GitHubLegacyCalloutTransformer{}, 8000),
+ util.Prioritized(&callout.GitHubCalloutTransformer{}, 9000),
+ util.Prioritized(&ASTTransformer{}, 10000),
+ ),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithUnsafe(),
+ ),
+ )
+
+ // Override the original Tasklist renderer!
+ specMarkdown.Renderer().AddOptions(
+ renderer.WithNodeRenderers(
+ util.Prioritized(callout.NewGitHubCalloutHTMLRenderer(), 10),
+ util.Prioritized(NewHTMLRenderer(), 10),
+ ),
+ )
+ })
+ return specMarkdown
+}
+
+// actualRender renders Markdown to HTML without handling special links.
+func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ converter := SpecializedMarkdown()
+ lw := &limitWriter{
+ w: output,
+ limit: setting.UI.MaxDisplayFileSize * 3,
+ }
+
+ // FIXME: should we include a timeout to abort the renderer if it takes too long?
+ defer func() {
+ err := recover()
+ if err == nil {
+ return
+ }
+
+ log.Warn("Unable to render markdown due to panic in goldmark: %v", err)
+ if log.IsDebug() {
+ log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2))
+ }
+ }()
+
+ // FIXME: Don't read all to memory, but goldmark doesn't support
+ pc := newParserContext(ctx)
+ buf, err := io.ReadAll(input)
+ if err != nil {
+ log.Error("Unable to ReadAll: %v", err)
+ return err
+ }
+ buf = giteautil.NormalizeEOL(buf)
+
+ // Preserve original length.
+ bufWithMetadataLength := len(buf)
+
+ rc := &RenderConfig{
+ Meta: markup.RenderMetaAsDetails,
+ Icon: "table",
+ Lang: "",
+ }
+ buf, _ = ExtractMetadataBytes(buf, rc)
+
+ metaLength := bufWithMetadataLength - len(buf)
+ if metaLength < 0 {
+ metaLength = 0
+ }
+ rc.metaLength = metaLength
+
+ pc.Set(renderConfigKey, rc)
+
+ if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil {
+ log.Error("Unable to render: %v", err)
+ return err
+ }
+
+ return nil
+}
+
+// Note: The output of this method must get sanitized.
+func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ defer func() {
+ err := recover()
+ if err == nil {
+ return
+ }
+
+ log.Warn("Unable to render markdown due to panic in goldmark - will return raw bytes")
+ if log.IsDebug() {
+ log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2))
+ }
+ _, err = io.Copy(output, input)
+ if err != nil {
+ log.Error("io.Copy failed: %v", err)
+ }
+ }()
+ return actualRender(ctx, input, output)
+}
+
+// MarkupName describes markup's name
+var MarkupName = "markdown"
+
+func init() {
+ markup.RegisterRenderer(Renderer{})
+}
+
+// Renderer implements markup.Renderer
+type Renderer struct{}
+
+var _ markup.PostProcessRenderer = (*Renderer)(nil)
+
+// Name implements markup.Renderer
+func (Renderer) Name() string {
+ return MarkupName
+}
+
+// NeedPostProcess implements markup.PostProcessRenderer
+func (Renderer) NeedPostProcess() bool { return true }
+
+// Extensions implements markup.Renderer
+func (Renderer) Extensions() []string {
+ return setting.Markdown.FileExtensions
+}
+
+// SanitizerRules implements markup.Renderer
+func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return []setting.MarkupSanitizerRule{}
+}
+
+// Render implements markup.Renderer
+func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ return render(ctx, input, output)
+}
+
+// Render renders Markdown to HTML with all specific handling stuff.
+func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ if ctx.Type == "" {
+ ctx.Type = MarkupName
+ }
+ return markup.Render(ctx, input, output)
+}
+
+// RenderString renders Markdown string to HTML with all specific handling stuff and return string
+func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
+ var buf strings.Builder
+ if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return template.HTML(buf.String()), nil
+}
+
+// RenderRaw renders Markdown to HTML without handling special links.
+func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ rd, wr := io.Pipe()
+ defer func() {
+ _ = rd.Close()
+ _ = wr.Close()
+ }()
+
+ go func() {
+ if err := render(ctx, input, wr); err != nil {
+ _ = wr.CloseWithError(err)
+ return
+ }
+ _ = wr.Close()
+ }()
+
+ return markup.SanitizeReader(rd, "", output)
+}
+
+// RenderRawString renders Markdown to HTML without handling special links and return string
+func RenderRawString(ctx *markup.RenderContext, content string) (string, error) {
+ var buf strings.Builder
+ if err := RenderRaw(ctx, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go
new file mode 100644
index 0000000..e3dc6c9
--- /dev/null
+++ b/modules/markup/markdown/markdown_test.go
@@ -0,0 +1,1359 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown_test
+
+import (
+ "context"
+ "html/template"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ AppURL = "http://localhost:3000/"
+ FullURL = AppURL + "gogits/gogs/"
+)
+
+// these values should match the const above
+var localMetas = map[string]string{
+ "user": "gogits",
+ "repo": "gogs",
+ "repoPath": "../../../tests/gitea-repositories-meta/user13/repo11.git/",
+}
+
+func TestMain(m *testing.M) {
+ unittest.InitSettings()
+ if err := git.InitSimple(context.Background()); err != nil {
+ log.Fatal("git init failed, err: %v", err)
+ }
+ markup.Init(&markup.ProcessorHelper{
+ IsUsernameMentionable: func(ctx context.Context, username string) bool {
+ return username == "r-lyeh"
+ },
+ })
+ os.Exit(m.Run())
+}
+
+func TestRender_StandardLinks(t *testing.T) {
+ setting.AppURL = AppURL
+
+ test := func(input, expected, expectedWiki string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+
+ buffer, err = markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ IsWiki: true,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
+ }
+
+ googleRendered := `<p><a href="https://google.com/" rel="nofollow">https://google.com/</a></p>`
+ test("<https://google.com/>", googleRendered, googleRendered)
+
+ lnk := util.URLJoin(FullURL, "WikiPage")
+ lnkWiki := util.URLJoin(FullURL, "wiki", "WikiPage")
+ test("[WikiPage](WikiPage)",
+ `<p><a href="`+lnk+`" rel="nofollow">WikiPage</a></p>`,
+ `<p><a href="`+lnkWiki+`" rel="nofollow">WikiPage</a></p>`)
+}
+
+func TestRender_Images(t *testing.T) {
+ setting.AppURL = AppURL
+
+ test := func(input, expected string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ }
+
+ url := "../../.images/src/02/train.jpg"
+ title := "Train"
+ href := "https://gitea.io"
+ result := util.URLJoin(FullURL, url)
+ // hint: With Markdown v2.5.2, there is a new syntax: [link](URL){:target="_blank"} , but we do not support it now
+
+ test(
+ "!["+title+"]("+url+")",
+ `<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
+
+ test(
+ "[["+title+"|"+url+"]]",
+ `<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
+ test(
+ "[!["+title+"]("+url+")]("+href+")",
+ `<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
+
+ test(
+ "!["+title+"]("+url+")",
+ `<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
+
+ test(
+ "[["+title+"|"+url+"]]",
+ `<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
+ test(
+ "[!["+title+"]("+url+")]("+href+")",
+ `<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
+}
+
+func testAnswers(baseURLContent, baseURLImages string) []string {
+ return []string{
+ `<p>Wiki! Enjoy :)</p>
+<ul>
+<li><a href="` + baseURLContent + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
+<li><a href="` + baseURLContent + `/Tips" rel="nofollow">Tips</a></li>
+</ul>
+<p>See commit <a href="/gogits/gogs/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
+<p>Ideas and codes</p>
+<ul>
+<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
+<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/gogits/gogs/issues/786" class="ref-issue" rel="nofollow">#786</a></li>
+<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
+<li><a href="` + baseURLContent + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
+<li><a href="` + baseURLContent + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
+</ul>
+`,
+ `<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2>
+<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
+<h2 id="user-content-quick-links">Quick Links</h2>
+<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
+<table>
+<thead>
+<tr>
+<th><a href="` + baseURLImages + `/images/icon-install.png" rel="nofollow"><img src="` + baseURLImages + `/images/icon-install.png" title="icon-install.png" alt="images/icon-install.png"/></a></th>
+<th><a href="` + baseURLContent + `/Installation" rel="nofollow">Installation</a></th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td><a href="` + baseURLImages + `/images/icon-usage.png" rel="nofollow"><img src="` + baseURLImages + `/images/icon-usage.png" title="icon-usage.png" alt="images/icon-usage.png"/></a></td>
+<td><a href="` + baseURLContent + `/Usage" rel="nofollow">Usage</a></td>
+</tr>
+</tbody>
+</table>
+`,
+ `<p><a href="http://www.excelsiorjet.com/" rel="nofollow">Excelsior JET</a> allows you to create native executables for Windows, Linux and Mac OS X.</p>
+<ol>
+<li><a href="https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop" rel="nofollow">Package your libGDX application</a><br/>
+<a href="` + baseURLImages + `/images/1.png" rel="nofollow"><img src="` + baseURLImages + `/images/1.png" title="1.png" alt="images/1.png"/></a></li>
+<li>Perform a test run by hitting the Run! button.<br/>
+<a href="` + baseURLImages + `/images/2.png" rel="nofollow"><img src="` + baseURLImages + `/images/2.png" title="2.png" alt="images/2.png"/></a></li>
+</ol>
+<h2 id="user-content-custom-id">More tests</h2>
+<p>(from <a href="https://www.markdownguide.org/extended-syntax/" rel="nofollow">https://www.markdownguide.org/extended-syntax/</a>)</p>
+<h3 id="user-content-checkboxes">Checkboxes</h3>
+<ul>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="434"/>unchecked</li>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="450" checked=""/>checked</li>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="464"/>still unchecked</li>
+</ul>
+<h3 id="user-content-definition-list">Definition list</h3>
+<dl>
+<dt>First Term</dt>
+<dd>This is the definition of the first term.</dd>
+<dt>Second Term</dt>
+<dd>This is one definition of the second term.</dd>
+<dd>This is another definition of the second term.</dd>
+</dl>
+<h3 id="user-content-footnotes">Footnotes</h3>
+<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1</a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">2</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-1">
+<p>This is the first footnote. <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
+</li>
+<li id="fn:user-content-bignote">
+<p>Here is one with multiple paragraphs and code.</p>
+<p>Indent paragraphs to include them in the footnote.</p>
+<p><code>{ my code }</code></p>
+<p>Add as many paragraphs as you like. <a href="#fnref:user-content-bignote" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`, `<ul>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="3"/> If you want to rebase/retry this PR, click this checkbox.</li>
+</ul>
+<hr/>
+<p>This PR has been generated by <a href="https://github.com/renovatebot/renovate" rel="nofollow">Renovate Bot</a>.</p>
+`,
+ }
+}
+
+// Test cases without ambiguous links
+var sameCases = []string{
+ // dear imgui wiki markdown extract: special wiki syntax
+ `Wiki! Enjoy :)
+- [[Links, Language bindings, Engine bindings|Links]]
+- [[Tips]]
+
+See commit 65f1bf27bc
+
+Ideas and codes
+
+- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786
+- Bezier widget (by @r-lyeh) ` + AppURL + `gogits/gogs/issues/786
+- Node graph editors https://github.com/ocornut/imgui/issues/306
+- [[Memory Editor|memory_editor_example]]
+- [[Plot var helper|plot_var_example]]`,
+ // wine-staging wiki home extract: tables, special wiki syntax, images
+ `## What is Wine Staging?
+**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
+
+## Quick Links
+Here are some links to the most important topics. You can find the full list of pages at the sidebar.
+
+| [[images/icon-install.png]] | [[Installation]] |
+|--------------------------------|----------------------------------------------------------|
+| [[images/icon-usage.png]] | [[Usage]] |
+`,
+ // libgdx wiki page: inline images with special syntax
+ `[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X.
+
+1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop)
+[[images/1.png]]
+2. Perform a test run by hitting the Run! button.
+[[images/2.png]]
+
+## More tests {#custom-id}
+
+(from https://www.markdownguide.org/extended-syntax/)
+
+### Checkboxes
+
+- [ ] unchecked
+- [x] checked
+- [ ] still unchecked
+
+### Definition list
+
+First Term
+: This is the definition of the first term.
+
+Second Term
+: This is one definition of the second term.
+: This is another definition of the second term.
+
+### Footnotes
+
+Here is a simple footnote,[^1] and here is a longer one.[^bignote]
+
+[^1]: This is the first footnote.
+
+[^bignote]: Here is one with multiple paragraphs and code.
+
+ Indent paragraphs to include them in the footnote.
+
+ ` + "`{ my code }`" + `
+
+ Add as many paragraphs as you like.
+`,
+ `
+- [ ] <!-- rebase-check --> If you want to rebase/retry this PR, click this checkbox.
+
+---
+
+This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
+
+<!-- test-comment -->`,
+}
+
+func TestTotal_RenderWiki(t *testing.T) {
+ setting.AppURL = AppURL
+
+ answers := testAnswers(util.URLJoin(FullURL, "wiki"), util.URLJoin(FullURL, "wiki", "raw"))
+
+ for i := 0; i < len(sameCases); i++ {
+ line, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ Metas: localMetas,
+ IsWiki: true,
+ }, sameCases[i])
+ require.NoError(t, err)
+ assert.Equal(t, template.HTML(answers[i]), line)
+ }
+
+ testCases := []string{
+ // Guard wiki sidebar: special syntax
+ `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
+ // rendered
+ `<p><a href="` + FullURL + `wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
+`,
+ // special syntax
+ `[[Name|Link]]`,
+ // rendered
+ `<p><a href="` + FullURL + `wiki/Link" rel="nofollow">Name</a></p>
+`,
+ }
+
+ for i := 0; i < len(testCases); i += 2 {
+ line, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ IsWiki: true,
+ }, testCases[i])
+ require.NoError(t, err)
+ assert.Equal(t, template.HTML(testCases[i+1]), line)
+ }
+}
+
+func TestTotal_RenderString(t *testing.T) {
+ setting.AppURL = AppURL
+
+ answers := testAnswers(util.URLJoin(FullURL, "src", "master"), util.URLJoin(FullURL, "media", "master"))
+
+ for i := 0; i < len(sameCases); i++ {
+ line, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ BranchPath: "master",
+ },
+ Metas: localMetas,
+ }, sameCases[i])
+ require.NoError(t, err)
+ assert.Equal(t, template.HTML(answers[i]), line)
+ }
+
+ testCases := []string{}
+
+ for i := 0; i < len(testCases); i += 2 {
+ line, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ },
+ }, testCases[i])
+ require.NoError(t, err)
+ assert.Equal(t, template.HTML(testCases[i+1]), line)
+ }
+}
+
+func TestRender_RenderParagraphs(t *testing.T) {
+ test := func(t *testing.T, str string, cnt int) {
+ res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, str)
+ require.NoError(t, err)
+ assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for unix should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
+
+ mac := strings.ReplaceAll(str, "\n", "\r")
+ res, err = markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, mac)
+ require.NoError(t, err)
+ assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for mac should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
+
+ dos := strings.ReplaceAll(str, "\n", "\r\n")
+ res, err = markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, dos)
+ require.NoError(t, err)
+ assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for windows should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
+ }
+
+ test(t, "\nOne\nTwo\nThree", 1)
+ test(t, "\n\nOne\nTwo\nThree", 1)
+ test(t, "\n\nOne\nTwo\nThree\n\n\n", 1)
+ test(t, "A\n\nB\nC\n", 2)
+ test(t, "A\n\n\nB\nC\n", 2)
+}
+
+func TestMarkdownRenderRaw(t *testing.T) {
+ testcases := [][]byte{
+ { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6267570554535936
+ 0x2a, 0x20, 0x2d, 0x0a, 0x09, 0x20, 0x60, 0x5b, 0x0a, 0x09, 0x20, 0x60,
+ 0x5b,
+ },
+ { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6278827345051648
+ 0x2d, 0x20, 0x2d, 0x0d, 0x09, 0x60, 0x0d, 0x09, 0x60,
+ },
+ { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6016973788020736[] = {
+ 0x7b, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x3d, 0x35, 0x7d, 0x0a, 0x3d,
+ },
+ }
+
+ for _, testcase := range testcases {
+ log.Info("Test markdown render error with fuzzy data: %x, the following errors can be recovered", testcase)
+ _, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, string(testcase))
+ require.NoError(t, err)
+ }
+}
+
+func TestRenderSiblingImages_Issue12925(t *testing.T) {
+ testcase := `![image1](/image1)
+![image2](/image2)
+`
+ expected := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"></a><br>
+<a href="/image2" target="_blank" rel="nofollow noopener"><img src="/image2" alt="image2"></a></p>
+`
+ res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase)
+ require.NoError(t, err)
+ assert.Equal(t, expected, res)
+}
+
+func TestRenderEmojiInLinks_Issue12331(t *testing.T) {
+ testcase := `[Link with emoji :moon: in text](https://gitea.io)`
+ expected := `<p><a href="https://gitea.io" rel="nofollow">Link with emoji <span class="emoji" aria-label="waxing gibbous moon">🌔</span> in text</a></p>
+`
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase)
+ require.NoError(t, err)
+ assert.Equal(t, template.HTML(expected), res)
+}
+
+func TestColorPreview(t *testing.T) {
+ const nl = "\n"
+ positiveTests := []struct {
+ testcase string
+ expected string
+ }{
+ { // hex
+ "`#FF0000`",
+ `<p><code>#FF0000<span class="color-preview" style="background-color: #FF0000"></span></code></p>` + nl,
+ },
+ { // rgb
+ "`rgb(16, 32, 64)`",
+ `<p><code>rgb(16, 32, 64)<span class="color-preview" style="background-color: rgb(16, 32, 64)"></span></code></p>` + nl,
+ },
+ { // short hex
+ "This is the color white `#000`",
+ `<p>This is the color white <code>#000<span class="color-preview" style="background-color: #000"></span></code></p>` + nl,
+ },
+ { // hsl
+ "HSL stands for hue, saturation, and lightness. An example: `hsl(0, 100%, 50%)`.",
+ `<p>HSL stands for hue, saturation, and lightness. An example: <code>hsl(0, 100%, 50%)<span class="color-preview" style="background-color: hsl(0, 100%, 50%)"></span></code>.</p>` + nl,
+ },
+ { // uppercase hsl
+ "HSL stands for hue, saturation, and lightness. An example: `HSL(0, 100%, 50%)`.",
+ `<p>HSL stands for hue, saturation, and lightness. An example: <code>HSL(0, 100%, 50%)<span class="color-preview" style="background-color: HSL(0, 100%, 50%)"></span></code>.</p>` + nl,
+ },
+ }
+
+ for _, test := range positiveTests {
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase)
+ require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
+ assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase)
+ }
+
+ negativeTests := []string{
+ // not a color code
+ "`FF0000`",
+ // inside a code block
+ "```javascript" + nl + `const red = "#FF0000";` + nl + "```",
+ // no backticks
+ "rgb(166, 32, 64)",
+ // typo
+ "`hsI(0, 100%, 50%)`", // codespell-ignore
+ // looks like a color but not really
+ "`hsl(40, 60, 80)`",
+ }
+
+ for _, test := range negativeTests {
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test)
+ require.NoError(t, err, "Unexpected error in testcase: %q", test)
+ assert.NotContains(t, res, `<span class="color-preview" style="background-color: `, "Unexpected result in testcase %q", test)
+ }
+}
+
+func TestMathBlock(t *testing.T) {
+ const nl = "\n"
+ testcases := []struct {
+ testcase string
+ expected string
+ }{
+ {
+ "$a$",
+ `<p><code class="language-math is-loading">a</code></p>` + nl,
+ },
+ {
+ "$ a $",
+ `<p><code class="language-math is-loading">a</code></p>` + nl,
+ },
+ {
+ "$a$ $b$",
+ `<p><code class="language-math is-loading">a</code> <code class="language-math is-loading">b</code></p>` + nl,
+ },
+ {
+ `\(a\) \(b\)`,
+ `<p><code class="language-math is-loading">a</code> <code class="language-math is-loading">b</code></p>` + nl,
+ },
+ {
+ `$a$.`,
+ `<p><code class="language-math is-loading">a</code>.</p>` + nl,
+ },
+ {
+ `.$a$`,
+ `<p>.$a$</p>` + nl,
+ },
+ {
+ `$a a$b b$`,
+ `<p>$a a$b b$</p>` + nl,
+ },
+ {
+ `a a$b b`,
+ `<p>a a$b b</p>` + nl,
+ },
+ {
+ `a$b $a a$b b$`,
+ `<p>a$b $a a$b b$</p>` + nl,
+ },
+ {
+ "a$x$",
+ `<p>a$x$</p>` + nl,
+ },
+ {
+ "$x$a",
+ `<p>$x$a</p>` + nl,
+ },
+ {
+ "$$a$$",
+ `<pre class="code-block is-loading"><code class="chroma language-math display">a</code></pre>` + nl,
+ },
+ {
+ `\[a b\]`,
+ `<pre class="code-block is-loading"><code class="chroma language-math display">a b</code></pre>` + nl,
+ },
+ {
+ `\[a b]`,
+ `<p>[a b]</p>` + nl,
+ },
+ {
+ `$$a`,
+ `<p>$$a</p>` + nl,
+ },
+ {
+ "$a$ ($b$) [$c$] {$d$}",
+ `<p><code class="language-math is-loading">a</code> (<code class="language-math is-loading">b</code>) [$c$] {$d$}</p>` + nl,
+ },
+ {
+ "$$a$$ test",
+ `<p><code class="language-math display is-loading">a</code> test</p>` + nl,
+ },
+ {
+ "test $$a$$",
+ `<p>test <code class="language-math display is-loading">a</code></p>` + nl,
+ },
+ }
+
+ for _, test := range testcases {
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase)
+ require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
+ assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase)
+ }
+}
+
+func TestFootnote(t *testing.T) {
+ testcases := []struct {
+ testcase string
+ expected string
+ }{
+ {
+ `Citation needed[^0].
+[^0]: Source`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup>.</p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^0]`,
+ `<p>Citation needed[^0]</p>
+`,
+ },
+ {
+ `Citation needed[^1], Citation needed twice[^3]
+[^3]: Source`,
+ `<p>Citation needed[^1], Citation needed twice<sup id="fnref:user-content-3"><a href="#fn:user-content-3" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-3">
+<p>Source <a href="#fnref:user-content-3" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^1]: Source`,
+ `<p>Citation needed[^0]</p>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^0]: Source 1
+[^0]: Source 2`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+<p>Source 1 <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed![^0]
+[^0]: Source`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Trigger [^`,
+ `<p>Trigger [^</p>
+`,
+ },
+ {
+ `Trigger 2 [^0`,
+ `<p>Trigger 2 [^0</p>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^0]: Source with citation needed[^1]
+[^1]: Source`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+<p>Source with citation needed<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">2</a></sup> <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p>
+</li>
+<li id="fn:user-content-1">
+<p>Source <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^#]
+[^#]: Source`,
+ `<p>Citation needed<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-1">
+<p>Source <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^0]
+ [^0]: Source`,
+ `<p>Citation needed[^0]<br/>
+[^0]: Source</p>
+`,
+ },
+ {
+ `[^0]: Source
+
+Citation needed[^0].`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup>.</p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^]
+[^]: Source`,
+ `<p>Citation needed[^]<br/>
+[^]: Source</p>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^0] Source`,
+ `<p>Citation needed[^0]<br/>
+[^0] Source</p>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^0 Source`,
+ `<p>Citation needed[^0]<br/>
+[^0 Source</p>
+`,
+ },
+ {
+ `Citation needed[^0] [^0]: Source`,
+ `<p>Citation needed[^0] [^0]: Source</p>
+`,
+ },
+ {
+ `Citation needed[^Source here 0 # 9-3]
+[^Source here 0 # 9-3]: Source`,
+ `<p>Citation needed<sup id="fnref:user-content-source-here-0-9-3"><a href="#fn:user-content-source-here-0-9-3" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-source-here-0-9-3">
+<p>Source <a href="#fnref:user-content-source-here-0-9-3" rel="nofollow">↩︎</a></p>
+</li>
+</ol>
+</div>
+`,
+ },
+ {
+ `Citation needed[^0]
+[^0]:`,
+ `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p>
+<div>
+<hr/>
+<ol>
+<li id="fn:user-content-0">
+ <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></li>
+</ol>
+</div>
+`,
+ },
+ }
+ for _, test := range testcases {
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase)
+ require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
+ assert.Equal(t, test.expected, string(res), "Unexpected result in testcase %q", test.testcase)
+ }
+}
+
+func TestTaskList(t *testing.T) {
+ testcases := []struct {
+ testcase string
+ expected string
+ }{
+ {
+ // data-source-position should take into account YAML frontmatter.
+ `---
+foo: bar
+---
+- [ ] task 1`,
+ `<details><summary><i class="icon table"></i></summary><table>
+<thead>
+<tr>
+<th>foo</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+</details><ul>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="19"/>task 1</li>
+</ul>
+`,
+ },
+ }
+
+ for _, test := range testcases {
+ res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase)
+ require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
+ assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase)
+ }
+}
+
+func TestRenderLinks(t *testing.T) {
+ input := ` space @mention-user${SPACE}${SPACE}
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![local image](path/file)
+![local image](/path/file)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+:+1:
+mail@domain.com
+@mention-user test
+#123
+ space${SPACE}${SPACE}
+`
+ input = strings.ReplaceAll(input, "${SPACE}", " ") // replace ${SPACE} with " ", to avoid some editor's auto-trimming
+ cases := []struct {
+ Links markup.Links
+ IsWiki bool
+ Expected string
+ }{
+ { // 0
+ Links: markup.Links{},
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/src/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a><br/>
+<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/>
+<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 1
+ Links: markup.Links{},
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/wiki/raw/image.jpg" rel="nofollow"><img src="/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 2
+ Links: markup.Links{
+ Base: "https://gitea.io/",
+ },
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="https://gitea.io/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="https://gitea.io/src/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="https://gitea.io/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/image.jpg" alt="local image"/></a><br/>
+<a href="https://gitea.io/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/path/file" alt="local image"/></a><br/>
+<a href="https://gitea.io/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="https://gitea.io/image.jpg" rel="nofollow"><img src="https://gitea.io/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 3
+ Links: markup.Links{
+ Base: "https://gitea.io/",
+ },
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="https://gitea.io/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="https://gitea.io/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="https://gitea.io/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="https://gitea.io/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://gitea.io/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="https://gitea.io/wiki/raw/image.jpg" rel="nofollow"><img src="https://gitea.io/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 4
+ Links: markup.Links{
+ Base: "/relative/path",
+ },
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/relative/path/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/src/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/image.jpg" alt="local image"/></a><br/>
+<a href="/relative/path/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/path/file" alt="local image"/></a><br/>
+<a href="/relative/path/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/relative/path/image.jpg" rel="nofollow"><img src="/relative/path/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 5
+ Links: markup.Links{
+ Base: "/relative/path",
+ },
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 6
+ Links: markup.Links{
+ Base: "/user/repo",
+ BranchPath: "branch/main",
+ },
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/user/repo/src/branch/main/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/src/branch/main/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/media/branch/main/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/image.jpg" alt="local image"/></a><br/>
+<a href="/user/repo/media/branch/main/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/path/file" alt="local image"/></a><br/>
+<a href="/user/repo/media/branch/main/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/user/repo/media/branch/main/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 7
+ Links: markup.Links{
+ Base: "/relative/path",
+ BranchPath: "branch/main",
+ },
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 8
+ Links: markup.Links{
+ Base: "/user/repo",
+ TreePath: "sub/folder",
+ },
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/user/repo/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/src/sub/folder/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/image.jpg" alt="local image"/></a><br/>
+<a href="/user/repo/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/path/file" alt="local image"/></a><br/>
+<a href="/user/repo/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/user/repo/image.jpg" rel="nofollow"><img src="/user/repo/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 9
+ Links: markup.Links{
+ Base: "/relative/path",
+ TreePath: "sub/folder",
+ },
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 10
+ Links: markup.Links{
+ Base: "/user/repo",
+ BranchPath: "branch/main",
+ TreePath: "sub/folder",
+ },
+ IsWiki: false,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/user/repo/src/branch/main/sub/folder/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/src/branch/main/sub/folder/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/user/repo/media/branch/main/sub/folder/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" alt="local image"/></a><br/>
+<a href="/user/repo/media/branch/main/sub/folder/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/path/file" alt="local image"/></a><br/>
+<a href="/user/repo/media/branch/main/sub/folder/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/user/repo/media/branch/main/sub/folder/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ { // 11
+ Links: markup.Links{
+ Base: "/relative/path",
+ BranchPath: "branch/main",
+ TreePath: "sub/folder",
+ },
+ IsWiki: true,
+ Expected: `<p>space @mention-user<br/>
+/just/a/path.bin<br/>
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/>
+<a href="https://example.com" rel="nofollow">remote link</a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
+<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span><br/>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
+@mention-user test<br/>
+#123<br/>
+space</p>
+`,
+ },
+ }
+
+ for i, c := range cases {
+ result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input)
+ require.NoError(t, err, "Unexpected error in testcase: %v", i)
+ assert.Equal(t, template.HTML(c.Expected), result, "Unexpected result in testcase %v", i)
+ }
+}
+
+func TestCustomMarkdownURL(t *testing.T) {
+ defer test.MockVariableValue(&setting.Markdown.CustomURLSchemes, []string{"abp"})()
+ setting.AppURL = AppURL
+
+ test := func(input, expected string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: FullURL,
+ BranchPath: "branch/main",
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ }
+
+ test("[test](abp:subscribe?location=https://codeberg.org/filters.txt&amp;title=joy)",
+ `<p><a href="abp:subscribe?location=https://codeberg.org/filters.txt&amp;title=joy" rel="nofollow">test</a></p>`)
+
+ // Ensure that the schema itself without `:` is still made absolute.
+ test("[test](abp)",
+ `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/abp" rel="nofollow">test</a></p>`)
+}
+
+func TestYAMLMeta(t *testing.T) {
+ setting.AppURL = AppURL
+
+ test := func(input, expected string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ }
+
+ test(`---
+include_toc: true
+---
+## Header`,
+ `<details><summary><i class="icon table"></i></summary><table>
+<thead>
+<tr>
+<th>include_toc</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>true</td>
+</tr>
+</tbody>
+</table>
+</details><details><summary>toc</summary><ul>
+<li>
+<a href="#user-content-header" rel="nofollow">Header</a></li>
+</ul>
+</details><h2 id="user-content-header">Header</h2>`)
+
+ test(`---
+key: value
+---`,
+ `<details><summary><i class="icon table"></i></summary><table>
+<thead>
+<tr>
+<th>key</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>value</td>
+</tr>
+</tbody>
+</table>
+</details>`)
+
+ test("---\n---\n",
+ `<hr/>
+<hr/>`)
+
+ test(`---
+gitea:
+ details_icon: smiley
+ include_toc: true
+---
+# Another header`,
+ `<details><summary><i class="icon smiley"></i></summary><table>
+<thead>
+<tr>
+<th>gitea</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td><table>
+<thead>
+<tr>
+<th>details_icon</th>
+<th>include_toc</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>smiley</td>
+<td>true</td>
+</tr>
+</tbody>
+</table>
+</td>
+</tr>
+</tbody>
+</table>
+</details><details><summary>toc</summary><ul>
+<li>
+<a href="#user-content-another-header" rel="nofollow">Another header</a></li>
+</ul>
+</details><h1 id="user-content-another-header">Another header</h1>`)
+
+ test(`---
+gitea:
+ meta: table
+key: value
+---`, `<table>
+<thead>
+<tr>
+<th>gitea</th>
+<th>key</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td><table>
+<thead>
+<tr>
+<th>meta</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>table</td>
+</tr>
+</tbody>
+</table>
+</td>
+<td>value</td>
+</tr>
+</tbody>
+</table>`)
+}
+
+func TestCallout(t *testing.T) {
+ setting.AppURL = AppURL
+
+ test := func(input, expected string) {
+ buffer, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
+ }
+
+ test(">\n0", "<blockquote>\n</blockquote>\n<p>0</p>")
+}
diff --git a/modules/markup/markdown/math/block_node.go b/modules/markup/markdown/math/block_node.go
new file mode 100644
index 0000000..10d17ff
--- /dev/null
+++ b/modules/markup/markdown/math/block_node.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import "github.com/yuin/goldmark/ast"
+
+// Block represents a display math block e.g. $$...$$ or \[...\]
+type Block struct {
+ ast.BaseBlock
+ Dollars bool
+ Indent int
+ Closed bool
+}
+
+// KindBlock is the node kind for math blocks
+var KindBlock = ast.NewNodeKind("MathBlock")
+
+// NewBlock creates a new math Block
+func NewBlock(dollars bool, indent int) *Block {
+ return &Block{
+ Dollars: dollars,
+ Indent: indent,
+ }
+}
+
+// Dump dumps the block to a string
+func (n *Block) Dump(source []byte, level int) {
+ m := map[string]string{}
+ ast.DumpHelper(n, source, level, m, nil)
+}
+
+// Kind returns KindBlock for math Blocks
+func (n *Block) Kind() ast.NodeKind {
+ return KindBlock
+}
+
+// IsRaw returns true as this block should not be processed further
+func (n *Block) IsRaw() bool {
+ return true
+}
diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go
new file mode 100644
index 0000000..527df84
--- /dev/null
+++ b/modules/markup/markdown/math/block_parser.go
@@ -0,0 +1,125 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "bytes"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+type blockParser struct {
+ parseDollars bool
+}
+
+// NewBlockParser creates a new math BlockParser
+func NewBlockParser(parseDollarBlocks bool) parser.BlockParser {
+ return &blockParser{
+ parseDollars: parseDollarBlocks,
+ }
+}
+
+// Open parses the current line and returns a result of parsing.
+func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
+ line, segment := reader.PeekLine()
+ pos := pc.BlockOffset()
+ if pos == -1 || len(line[pos:]) < 2 {
+ return nil, parser.NoChildren
+ }
+
+ dollars := false
+ if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' {
+ dollars = true
+ } else if line[pos] != '\\' || line[pos+1] != '[' {
+ return nil, parser.NoChildren
+ }
+
+ node := NewBlock(dollars, pos)
+
+ // Now we need to check if the ending block is on the segment...
+ endBytes := []byte{'\\', ']'}
+ if dollars {
+ endBytes = []byte{'$', '$'}
+ }
+ idx := bytes.Index(line[pos+2:], endBytes)
+ if idx >= 0 {
+ // for case $$ ... $$ any other text
+ for i := pos + idx + 4; i < len(line); i++ {
+ if line[i] != ' ' && line[i] != '\n' {
+ return nil, parser.NoChildren
+ }
+ }
+ segment.Stop = segment.Start + idx + 2
+ reader.Advance(segment.Len() - 1)
+ segment.Start += 2
+ node.Lines().Append(segment)
+ node.Closed = true
+ return node, parser.Close | parser.NoChildren
+ }
+
+ return nil, parser.NoChildren
+}
+
+// Continue parses the current line and returns a result of parsing.
+func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
+ block := node.(*Block)
+ if block.Closed {
+ return parser.Close
+ }
+
+ line, segment := reader.PeekLine()
+ w, pos := util.IndentWidth(line, 0)
+ if w < 4 {
+ if block.Dollars {
+ i := pos
+ for ; i < len(line) && line[i] == '$'; i++ {
+ }
+ length := i - pos
+ if length >= 2 && util.IsBlank(line[i:]) {
+ reader.Advance(segment.Stop - segment.Start - segment.Padding)
+ block.Closed = true
+ return parser.Close
+ }
+ } else if len(line[pos:]) > 1 && line[pos] == '\\' && line[pos+1] == ']' && util.IsBlank(line[pos+2:]) {
+ reader.Advance(segment.Stop - segment.Start - segment.Padding)
+ block.Closed = true
+ return parser.Close
+ }
+ }
+
+ pos, padding := util.IndentPosition(line, 0, block.Indent)
+ seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding)
+ node.Lines().Append(seg)
+ reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding)
+ return parser.Continue | parser.NoChildren
+}
+
+// Close will be called when the parser returns Close.
+func (b *blockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
+ // noop
+}
+
+// CanInterruptParagraph returns true if the parser can interrupt paragraphs,
+// otherwise false.
+func (b *blockParser) CanInterruptParagraph() bool {
+ return true
+}
+
+// CanAcceptIndentedLine returns true if the parser can open new node when
+// the given line is being indented more than 3 spaces.
+func (b *blockParser) CanAcceptIndentedLine() bool {
+ return false
+}
+
+// Trigger returns a list of characters that triggers Parse method of
+// this parser.
+// If Trigger returns a nil, Open will be called with any lines.
+//
+// We leave this as nil as our parse method is quick enough
+func (b *blockParser) Trigger() []byte {
+ return nil
+}
diff --git a/modules/markup/markdown/math/block_renderer.go b/modules/markup/markdown/math/block_renderer.go
new file mode 100644
index 0000000..84817ef
--- /dev/null
+++ b/modules/markup/markdown/math/block_renderer.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ gast "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/util"
+)
+
+// BlockRenderer represents a renderer for math Blocks
+type BlockRenderer struct{}
+
+// NewBlockRenderer creates a new renderer for math Blocks
+func NewBlockRenderer() renderer.NodeRenderer {
+ return &BlockRenderer{}
+}
+
+// RegisterFuncs registers the renderer for math Blocks
+func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindBlock, r.renderBlock)
+}
+
+func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) {
+ l := n.Lines().Len()
+ for i := 0; i < l; i++ {
+ line := n.Lines().At(i)
+ _, _ = w.Write(util.EscapeHTML(line.Value(source)))
+ }
+}
+
+func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
+ n := node.(*Block)
+ if entering {
+ _, _ = w.WriteString(`<pre class="code-block is-loading"><code class="chroma language-math display">`)
+ r.writeLines(w, source, n)
+ } else {
+ _, _ = w.WriteString(`</code></pre>` + "\n")
+ }
+ return gast.WalkContinue, nil
+}
diff --git a/modules/markup/markdown/math/inline_block_node.go b/modules/markup/markdown/math/inline_block_node.go
new file mode 100644
index 0000000..c92d0c8
--- /dev/null
+++ b/modules/markup/markdown/math/inline_block_node.go
@@ -0,0 +1,31 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "github.com/yuin/goldmark/ast"
+)
+
+// InlineBlock represents inline math e.g. $$...$$
+type InlineBlock struct {
+ Inline
+}
+
+// InlineBlock implements InlineBlock.
+func (n *InlineBlock) InlineBlock() {}
+
+// KindInlineBlock is the kind for math inline block
+var KindInlineBlock = ast.NewNodeKind("MathInlineBlock")
+
+// Kind returns KindInlineBlock
+func (n *InlineBlock) Kind() ast.NodeKind {
+ return KindInlineBlock
+}
+
+// NewInlineBlock creates a new ast math inline block node
+func NewInlineBlock() *InlineBlock {
+ return &InlineBlock{
+ Inline{},
+ }
+}
diff --git a/modules/markup/markdown/math/inline_node.go b/modules/markup/markdown/math/inline_node.go
new file mode 100644
index 0000000..2221a25
--- /dev/null
+++ b/modules/markup/markdown/math/inline_node.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/util"
+)
+
+// Inline represents inline math e.g. $...$ or \(...\)
+type Inline struct {
+ ast.BaseInline
+}
+
+// Inline implements Inline.Inline.
+func (n *Inline) Inline() {}
+
+// IsBlank returns if this inline node is empty
+func (n *Inline) IsBlank(source []byte) bool {
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ text := c.(*ast.Text).Segment
+ if !util.IsBlank(text.Value(source)) {
+ return false
+ }
+ }
+ return true
+}
+
+// Dump renders this inline math as debug
+func (n *Inline) Dump(source []byte, level int) {
+ ast.DumpHelper(n, source, level, nil, nil)
+}
+
+// KindInline is the kind for math inline
+var KindInline = ast.NewNodeKind("MathInline")
+
+// Kind returns KindInline
+func (n *Inline) Kind() ast.NodeKind {
+ return KindInline
+}
+
+// NewInline creates a new ast math inline node
+func NewInline() *Inline {
+ return &Inline{
+ BaseInline: ast.BaseInline{},
+ }
+}
diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go
new file mode 100644
index 0000000..b11195d
--- /dev/null
+++ b/modules/markup/markdown/math/inline_parser.go
@@ -0,0 +1,153 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "bytes"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+)
+
+type inlineParser struct {
+ start []byte
+ end []byte
+}
+
+var defaultInlineDollarParser = &inlineParser{
+ start: []byte{'$'},
+ end: []byte{'$'},
+}
+
+var defaultDualDollarParser = &inlineParser{
+ start: []byte{'$', '$'},
+ end: []byte{'$', '$'},
+}
+
+// NewInlineDollarParser returns a new inline parser
+func NewInlineDollarParser() parser.InlineParser {
+ return defaultInlineDollarParser
+}
+
+func NewInlineDualDollarParser() parser.InlineParser {
+ return defaultDualDollarParser
+}
+
+var defaultInlineBracketParser = &inlineParser{
+ start: []byte{'\\', '('},
+ end: []byte{'\\', ')'},
+}
+
+// NewInlineDollarParser returns a new inline parser
+func NewInlineBracketParser() parser.InlineParser {
+ return defaultInlineBracketParser
+}
+
+// Trigger triggers this parser on $ or \
+func (parser *inlineParser) Trigger() []byte {
+ return parser.start
+}
+
+func isPunctuation(b byte) bool {
+ return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':'
+}
+
+func isBracket(b byte) bool {
+ return b == ')'
+}
+
+func isAlphanumeric(b byte) bool {
+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
+}
+
+// Parse parses the current line and returns a result of parsing.
+func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+ line, _ := block.PeekLine()
+
+ if !bytes.HasPrefix(line, parser.start) {
+ // We'll catch this one on the next time round
+ return nil
+ }
+
+ precedingCharacter := block.PrecendingCharacter()
+ if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) {
+ // need to exclude things like `a$` from being considered a start
+ return nil
+ }
+
+ // move the opener marker point at the start of the text
+ opener := len(parser.start)
+
+ // Now look for an ending line
+ ender := opener
+ for {
+ pos := bytes.Index(line[ender:], parser.end)
+ if pos < 0 {
+ return nil
+ }
+
+ ender += pos
+
+ // Now we want to check the character at the end of our parser section
+ // that is ender + len(parser.end) and check if char before ender is '\'
+ pos = ender + len(parser.end)
+ if len(line) <= pos {
+ break
+ }
+ suceedingCharacter := line[pos]
+ // check valid ending character
+ if !isPunctuation(suceedingCharacter) &&
+ !(suceedingCharacter == ' ') &&
+ !(suceedingCharacter == '\n') &&
+ !isBracket(suceedingCharacter) {
+ return nil
+ }
+ if line[ender-1] != '\\' {
+ break
+ }
+
+ // move the pointer onwards
+ ender += len(parser.end)
+ }
+
+ block.Advance(opener)
+ _, pos := block.Position()
+ var node ast.Node
+ if parser == defaultDualDollarParser {
+ node = NewInlineBlock()
+ } else {
+ node = NewInline()
+ }
+ segment := pos.WithStop(pos.Start + ender - opener)
+ node.AppendChild(node, ast.NewRawTextSegment(segment))
+ block.Advance(ender - opener + len(parser.end))
+
+ if parser == defaultDualDollarParser {
+ trimBlock(&(node.(*InlineBlock)).Inline, block)
+ } else {
+ trimBlock(node.(*Inline), block)
+ }
+ return node
+}
+
+func trimBlock(node *Inline, block text.Reader) {
+ if node.IsBlank(block.Source()) {
+ return
+ }
+
+ // trim first space and last space
+ first := node.FirstChild().(*ast.Text)
+ if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') {
+ return
+ }
+
+ last := node.LastChild().(*ast.Text)
+ if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') {
+ return
+ }
+
+ first.Segment = first.Segment.WithStart(first.Segment.Start + 1)
+ last.Segment = last.Segment.WithStop(last.Segment.Stop - 1)
+}
diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go
new file mode 100644
index 0000000..9684809
--- /dev/null
+++ b/modules/markup/markdown/math/inline_renderer.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "bytes"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/util"
+)
+
+// InlineRenderer is an inline renderer
+type InlineRenderer struct{}
+
+// NewInlineRenderer returns a new renderer for inline math
+func NewInlineRenderer() renderer.NodeRenderer {
+ return &InlineRenderer{}
+}
+
+func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ extraClass := ""
+ if _, ok := n.(*InlineBlock); ok {
+ extraClass = "display "
+ }
+ _, _ = w.WriteString(`<code class="language-math ` + extraClass + `is-loading">`)
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ segment := c.(*ast.Text).Segment
+ value := util.EscapeHTML(segment.Value(source))
+ if bytes.HasSuffix(value, []byte("\n")) {
+ _, _ = w.Write(value[:len(value)-1])
+ if c != n.LastChild() {
+ _, _ = w.Write([]byte(" "))
+ }
+ } else {
+ _, _ = w.Write(value)
+ }
+ }
+ return ast.WalkSkipChildren, nil
+ }
+ _, _ = w.WriteString(`</code>`)
+ return ast.WalkContinue, nil
+}
+
+// RegisterFuncs registers the renderer for inline math nodes
+func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindInline, r.renderInline)
+ reg.Register(KindInlineBlock, r.renderInline)
+}
diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go
new file mode 100644
index 0000000..3d9f376
--- /dev/null
+++ b/modules/markup/markdown/math/math.go
@@ -0,0 +1,108 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/util"
+)
+
+// Extension is a math extension
+type Extension struct {
+ enabled bool
+ parseDollarInline bool
+ parseDollarBlock bool
+}
+
+// Option is the interface Options should implement
+type Option interface {
+ SetOption(e *Extension)
+}
+
+type extensionFunc func(e *Extension)
+
+func (fn extensionFunc) SetOption(e *Extension) {
+ fn(e)
+}
+
+// Enabled enables or disables this extension
+func Enabled(enable ...bool) Option {
+ value := true
+ if len(enable) > 0 {
+ value = enable[0]
+ }
+ return extensionFunc(func(e *Extension) {
+ e.enabled = value
+ })
+}
+
+// WithInlineDollarParser enables or disables the parsing of $...$
+func WithInlineDollarParser(enable ...bool) Option {
+ value := true
+ if len(enable) > 0 {
+ value = enable[0]
+ }
+ return extensionFunc(func(e *Extension) {
+ e.parseDollarInline = value
+ })
+}
+
+// WithBlockDollarParser enables or disables the parsing of $$...$$
+func WithBlockDollarParser(enable ...bool) Option {
+ value := true
+ if len(enable) > 0 {
+ value = enable[0]
+ }
+ return extensionFunc(func(e *Extension) {
+ e.parseDollarBlock = value
+ })
+}
+
+// Math represents a math extension with default rendered delimiters
+var Math = &Extension{
+ enabled: true,
+ parseDollarBlock: true,
+ parseDollarInline: true,
+}
+
+// NewExtension creates a new math extension with the provided options
+func NewExtension(opts ...Option) *Extension {
+ r := &Extension{
+ enabled: true,
+ parseDollarBlock: true,
+ parseDollarInline: true,
+ }
+
+ for _, o := range opts {
+ o.SetOption(r)
+ }
+ return r
+}
+
+// Extend extends goldmark with our parsers and renderers
+func (e *Extension) Extend(m goldmark.Markdown) {
+ if !e.enabled {
+ return
+ }
+
+ m.Parser().AddOptions(parser.WithBlockParsers(
+ util.Prioritized(NewBlockParser(e.parseDollarBlock), 701),
+ ))
+
+ inlines := []util.PrioritizedValue{
+ util.Prioritized(NewInlineBracketParser(), 501),
+ }
+ if e.parseDollarInline {
+ inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 503),
+ util.Prioritized(NewInlineDualDollarParser(), 502))
+ }
+ m.Parser().AddOptions(parser.WithInlineParsers(inlines...))
+
+ m.Renderer().AddOptions(renderer.WithNodeRenderers(
+ util.Prioritized(NewBlockRenderer(), 501),
+ util.Prioritized(NewInlineRenderer(), 502),
+ ))
+}
diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go
new file mode 100644
index 0000000..e76b253
--- /dev/null
+++ b/modules/markup/markdown/meta.go
@@ -0,0 +1,103 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "bytes"
+ "errors"
+ "unicode"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+func isYAMLSeparator(line []byte) bool {
+ idx := 0
+ for ; idx < len(line); idx++ {
+ if line[idx] >= utf8.RuneSelf {
+ r, sz := utf8.DecodeRune(line[idx:])
+ if !unicode.IsSpace(r) {
+ return false
+ }
+ idx += sz
+ continue
+ }
+ if line[idx] != ' ' {
+ break
+ }
+ }
+ dashCount := 0
+ for ; idx < len(line); idx++ {
+ if line[idx] != '-' {
+ break
+ }
+ dashCount++
+ }
+ if dashCount < 3 {
+ return false
+ }
+ for ; idx < len(line); idx++ {
+ if line[idx] >= utf8.RuneSelf {
+ r, sz := utf8.DecodeRune(line[idx:])
+ if !unicode.IsSpace(r) {
+ return false
+ }
+ idx += sz
+ continue
+ }
+ if line[idx] != ' ' {
+ return false
+ }
+ }
+ return true
+}
+
+// ExtractMetadata consumes a markdown file, parses YAML frontmatter,
+// and returns the frontmatter metadata separated from the markdown content
+func ExtractMetadata(contents string, out any) (string, error) {
+ body, err := ExtractMetadataBytes([]byte(contents), out)
+ return string(body), err
+}
+
+// ExtractMetadata consumes a markdown file, parses YAML frontmatter,
+// and returns the frontmatter metadata separated from the markdown content
+func ExtractMetadataBytes(contents []byte, out any) ([]byte, error) {
+ var front, body []byte
+
+ start, end := 0, len(contents)
+ idx := bytes.IndexByte(contents[start:], '\n')
+ if idx >= 0 {
+ end = start + idx
+ }
+ line := contents[start:end]
+
+ if !isYAMLSeparator(line) {
+ return contents, errors.New("frontmatter must start with a separator line")
+ }
+ frontMatterStart := end + 1
+ for start = frontMatterStart; start < len(contents); start = end + 1 {
+ end = len(contents)
+ idx := bytes.IndexByte(contents[start:], '\n')
+ if idx >= 0 {
+ end = start + idx
+ }
+ line := contents[start:end]
+ if isYAMLSeparator(line) {
+ front = contents[frontMatterStart:start]
+ if end+1 < len(contents) {
+ body = contents[end+1:]
+ }
+ break
+ }
+ }
+
+ if len(front) == 0 {
+ return contents, errors.New("could not determine metadata")
+ }
+
+ if err := yaml.Unmarshal(front, out); err != nil {
+ return contents, err
+ }
+ return body, nil
+}
diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go
new file mode 100644
index 0000000..d341ae4
--- /dev/null
+++ b/modules/markup/markdown/meta_test.go
@@ -0,0 +1,110 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+/*
+IssueTemplate is a legacy to keep the unit tests working.
+Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
+*/
+type IssueTemplate struct {
+ Name string `json:"name" yaml:"name"`
+ Title string `json:"title" yaml:"title"`
+ About string `json:"about" yaml:"about"`
+ Labels []string `json:"labels" yaml:"labels"`
+ Ref string `json:"ref" yaml:"ref"`
+}
+
+func (it *IssueTemplate) Valid() bool {
+ return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != ""
+}
+
+func TestExtractMetadata(t *testing.T) {
+ t.Run("ValidFrontAndBody", func(t *testing.T) {
+ var meta IssueTemplate
+ body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest), &meta)
+ require.NoError(t, err)
+ assert.Equal(t, bodyTest, body)
+ assert.Equal(t, metaTest, meta)
+ assert.True(t, meta.Valid())
+ })
+
+ t.Run("NoFirstSeparator", func(t *testing.T) {
+ var meta IssueTemplate
+ _, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest), &meta)
+ require.Error(t, err)
+ })
+
+ t.Run("NoLastSeparator", func(t *testing.T) {
+ var meta IssueTemplate
+ _, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest), &meta)
+ require.Error(t, err)
+ })
+
+ t.Run("NoBody", func(t *testing.T) {
+ var meta IssueTemplate
+ body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest), &meta)
+ require.NoError(t, err)
+ assert.Equal(t, "", body)
+ assert.Equal(t, metaTest, meta)
+ assert.True(t, meta.Valid())
+ })
+}
+
+func TestExtractMetadataBytes(t *testing.T) {
+ t.Run("ValidFrontAndBody", func(t *testing.T) {
+ var meta IssueTemplate
+ body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest)), &meta)
+ require.NoError(t, err)
+ assert.Equal(t, bodyTest, string(body))
+ assert.Equal(t, metaTest, meta)
+ assert.True(t, meta.Valid())
+ })
+
+ t.Run("NoFirstSeparator", func(t *testing.T) {
+ var meta IssueTemplate
+ _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest)), &meta)
+ require.Error(t, err)
+ })
+
+ t.Run("NoLastSeparator", func(t *testing.T) {
+ var meta IssueTemplate
+ _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest)), &meta)
+ require.Error(t, err)
+ })
+
+ t.Run("NoBody", func(t *testing.T) {
+ var meta IssueTemplate
+ body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest)), &meta)
+ require.NoError(t, err)
+ assert.Equal(t, "", string(body))
+ assert.Equal(t, metaTest, meta)
+ assert.True(t, meta.Valid())
+ })
+}
+
+var (
+ sepTest = "-----"
+ frontTest = `name: Test
+about: "A Test"
+title: "Test Title"
+labels:
+ - bug
+ - "test label"`
+ bodyTest = "This is the body"
+ metaTest = IssueTemplate{
+ Name: "Test",
+ About: "A Test",
+ Title: "Test Title",
+ Labels: []string{"bug", "test label"},
+ }
+)
diff --git a/modules/markup/markdown/prefixed_id.go b/modules/markup/markdown/prefixed_id.go
new file mode 100644
index 0000000..63d7fad
--- /dev/null
+++ b/modules/markup/markdown/prefixed_id.go
@@ -0,0 +1,59 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "bytes"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/markup/common"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+type prefixedIDs struct {
+ values container.Set[string]
+}
+
+// Generate generates a new element id.
+func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
+ dft := []byte("id")
+ if kind == ast.KindHeading {
+ dft = []byte("heading")
+ }
+ return p.GenerateWithDefault(value, dft)
+}
+
+// GenerateWithDefault generates a new element id.
+func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte {
+ result := common.CleanValue(value)
+ if len(result) == 0 {
+ result = dft
+ }
+ if !bytes.HasPrefix(result, []byte("user-content-")) {
+ result = append([]byte("user-content-"), result...)
+ }
+ if p.values.Add(util.UnsafeBytesToString(result)) {
+ return result
+ }
+ for i := 1; ; i++ {
+ newResult := fmt.Sprintf("%s-%d", result, i)
+ if p.values.Add(newResult) {
+ return []byte(newResult)
+ }
+ }
+}
+
+// Put puts a given element id to the used ids table.
+func (p *prefixedIDs) Put(value []byte) {
+ p.values.Add(util.UnsafeBytesToString(value))
+}
+
+func newPrefixedIDs() *prefixedIDs {
+ return &prefixedIDs{
+ values: make(container.Set[string]),
+ }
+}
diff --git a/modules/markup/markdown/renderconfig.go b/modules/markup/markdown/renderconfig.go
new file mode 100644
index 0000000..f4c48d1
--- /dev/null
+++ b/modules/markup/markdown/renderconfig.go
@@ -0,0 +1,126 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+
+ "github.com/yuin/goldmark/ast"
+ "gopkg.in/yaml.v3"
+)
+
+// RenderConfig represents rendering configuration for this file
+type RenderConfig struct {
+ Meta markup.RenderMetaMode
+ Icon string
+ TOC string // "false": hide, "side"/empty: in sidebar, "main"/"true": in main view
+ Lang string
+ yamlNode *yaml.Node
+
+ // Used internally. Cannot be controlled by frontmatter.
+ metaLength int
+}
+
+func renderMetaModeFromString(s string) markup.RenderMetaMode {
+ switch strings.TrimSpace(strings.ToLower(s)) {
+ case "none":
+ return markup.RenderMetaAsNone
+ case "table":
+ return markup.RenderMetaAsTable
+ default: // "details"
+ return markup.RenderMetaAsDetails
+ }
+}
+
+// UnmarshalYAML implement yaml.v3 UnmarshalYAML
+func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error {
+ if rc == nil {
+ return nil
+ }
+
+ rc.yamlNode = value
+
+ type commonRenderConfig struct {
+ TOC string `yaml:"include_toc"`
+ Lang string `yaml:"lang"`
+ }
+ var basic commonRenderConfig
+ if err := value.Decode(&basic); err != nil {
+ return fmt.Errorf("unable to decode into commonRenderConfig %w", err)
+ }
+
+ if basic.Lang != "" {
+ rc.Lang = basic.Lang
+ }
+
+ rc.TOC = basic.TOC
+
+ type controlStringRenderConfig struct {
+ Gitea string `yaml:"gitea"`
+ }
+
+ var stringBasic controlStringRenderConfig
+
+ if err := value.Decode(&stringBasic); err == nil {
+ if stringBasic.Gitea != "" {
+ rc.Meta = renderMetaModeFromString(stringBasic.Gitea)
+ }
+ return nil
+ }
+
+ type yamlRenderConfig struct {
+ Meta *string `yaml:"meta"`
+ Icon *string `yaml:"details_icon"`
+ TOC *string `yaml:"include_toc"`
+ Lang *string `yaml:"lang"`
+ }
+
+ type yamlRenderConfigWrapper struct {
+ Gitea *yamlRenderConfig `yaml:"gitea"`
+ }
+
+ var cfg yamlRenderConfigWrapper
+ if err := value.Decode(&cfg); err != nil {
+ return fmt.Errorf("unable to decode into yamlRenderConfigWrapper %w", err)
+ }
+
+ if cfg.Gitea == nil {
+ return nil
+ }
+
+ if cfg.Gitea.Meta != nil {
+ rc.Meta = renderMetaModeFromString(*cfg.Gitea.Meta)
+ }
+
+ if cfg.Gitea.Icon != nil {
+ rc.Icon = strings.TrimSpace(strings.ToLower(*cfg.Gitea.Icon))
+ }
+
+ if cfg.Gitea.Lang != nil && *cfg.Gitea.Lang != "" {
+ rc.Lang = *cfg.Gitea.Lang
+ }
+
+ if cfg.Gitea.TOC != nil {
+ rc.TOC = *cfg.Gitea.TOC
+ }
+
+ return nil
+}
+
+func (rc *RenderConfig) toMetaNode() ast.Node {
+ if rc.yamlNode == nil {
+ return nil
+ }
+ switch rc.Meta {
+ case markup.RenderMetaAsTable:
+ return nodeToTable(rc.yamlNode)
+ case markup.RenderMetaAsDetails:
+ return nodeToDetails(rc.yamlNode, rc.Icon)
+ default:
+ return nil
+ }
+}
diff --git a/modules/markup/markdown/renderconfig_test.go b/modules/markup/markdown/renderconfig_test.go
new file mode 100644
index 0000000..c53acdc
--- /dev/null
+++ b/modules/markup/markdown/renderconfig_test.go
@@ -0,0 +1,162 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestRenderConfig_UnmarshalYAML(t *testing.T) {
+ tests := []struct {
+ name string
+ expected *RenderConfig
+ args string
+ }{
+ {
+ "empty", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "",
+ }, "",
+ },
+ {
+ "lang", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "test",
+ }, "lang: test",
+ },
+ {
+ "metatable", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "",
+ }, "gitea: table",
+ },
+ {
+ "metanone", &RenderConfig{
+ Meta: "none",
+ Icon: "table",
+ Lang: "",
+ }, "gitea: none",
+ },
+ {
+ "metadetails", &RenderConfig{
+ Meta: "details",
+ Icon: "table",
+ Lang: "",
+ }, "gitea: details",
+ },
+ {
+ "metawrong", &RenderConfig{
+ Meta: "details",
+ Icon: "table",
+ Lang: "",
+ }, "gitea: wrong",
+ },
+ {
+ "toc", &RenderConfig{
+ TOC: "true",
+ Meta: "table",
+ Icon: "table",
+ Lang: "",
+ }, "include_toc: true",
+ },
+ {
+ "tocfalse", &RenderConfig{
+ TOC: "false",
+ Meta: "table",
+ Icon: "table",
+ Lang: "",
+ }, "include_toc: false",
+ },
+ {
+ "toclang", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ TOC: "true",
+ Lang: "testlang",
+ }, `
+ include_toc: true
+ lang: testlang
+ `,
+ },
+ {
+ "complexlang", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "testlang",
+ }, `
+ gitea:
+ lang: testlang
+ `,
+ },
+ {
+ "complexlang2", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "testlang",
+ }, `
+ lang: notright
+ gitea:
+ lang: testlang
+`,
+ },
+ {
+ "complexlang", &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "testlang",
+ }, `
+ gitea:
+ lang: testlang
+`,
+ },
+ {
+ "complex2", &RenderConfig{
+ Lang: "two",
+ Meta: "table",
+ TOC: "true",
+ Icon: "smiley",
+ }, `
+ lang: one
+ include_toc: true
+ gitea:
+ details_icon: smiley
+ meta: table
+ include_toc: true
+ lang: two
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := &RenderConfig{
+ Meta: "table",
+ Icon: "table",
+ Lang: "",
+ }
+ if err := yaml.Unmarshal([]byte(strings.ReplaceAll(tt.args, "\t", " ")), got); err != nil {
+ t.Errorf("RenderConfig.UnmarshalYAML() error = %v\n%q", err, tt.args)
+ return
+ }
+
+ if got.Meta != tt.expected.Meta {
+ t.Errorf("Meta Expected %s Got %s", tt.expected.Meta, got.Meta)
+ }
+ if got.Icon != tt.expected.Icon {
+ t.Errorf("Icon Expected %s Got %s", tt.expected.Icon, got.Icon)
+ }
+ if got.Lang != tt.expected.Lang {
+ t.Errorf("Lang Expected %s Got %s", tt.expected.Lang, got.Lang)
+ }
+ if got.TOC != tt.expected.TOC {
+ t.Errorf("TOC Expected %q Got %q", tt.expected.TOC, got.TOC)
+ }
+ })
+ }
+}
diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go
new file mode 100644
index 0000000..38f744a
--- /dev/null
+++ b/modules/markup/markdown/toc.go
@@ -0,0 +1,54 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+ "net/url"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]string) ast.Node {
+ details := NewDetails()
+ summary := NewSummary()
+
+ for k, v := range detailsAttrs {
+ details.SetAttributeString(k, []byte(v))
+ }
+
+ summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc"))))
+ details.AppendChild(details, summary)
+ ul := ast.NewList('-')
+ details.AppendChild(details, ul)
+ currentLevel := 6
+ for _, header := range toc {
+ if header.Level < currentLevel {
+ currentLevel = header.Level
+ }
+ }
+ for _, header := range toc {
+ for currentLevel > header.Level {
+ ul = ul.Parent().(*ast.List)
+ currentLevel--
+ }
+ for currentLevel < header.Level {
+ newL := ast.NewList('-')
+ ul.AppendChild(ul, newL)
+ currentLevel++
+ ul = newL
+ }
+ li := ast.NewListItem(currentLevel * 2)
+ a := ast.NewLink()
+ a.Destination = []byte(fmt.Sprintf("#%s", url.QueryEscape(header.ID)))
+ a.AppendChild(a, ast.NewString([]byte(header.Text)))
+ li.AppendChild(li, a)
+ ul.AppendChild(ul, li)
+ }
+
+ return details
+}
diff --git a/modules/markup/markdown/transform_codespan.go b/modules/markup/markdown/transform_codespan.go
new file mode 100644
index 0000000..a2cd4fb
--- /dev/null
+++ b/modules/markup/markdown/transform_codespan.go
@@ -0,0 +1,56 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+// renderCodeSpan renders CodeSpan elements (like goldmark upstream does) but also renders ColorPreview elements.
+// See #21474 for reference
+func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ if n.Attributes() != nil {
+ _, _ = w.WriteString("<code")
+ html.RenderAttributes(w, n, html.CodeAttributeFilter)
+ _ = w.WriteByte('>')
+ } else {
+ _, _ = w.WriteString("<code>")
+ }
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ switch v := c.(type) {
+ case *ast.Text:
+ segment := v.Segment
+ value := segment.Value(source)
+ if bytes.HasSuffix(value, []byte("\n")) {
+ r.Writer.RawWrite(w, value[:len(value)-1])
+ r.Writer.RawWrite(w, []byte(" "))
+ } else {
+ r.Writer.RawWrite(w, value)
+ }
+ case *ColorPreview:
+ _, _ = w.WriteString(fmt.Sprintf(`<span class="color-preview" style="background-color: %v"></span>`, string(v.Color)))
+ }
+ }
+ return ast.WalkSkipChildren, nil
+ }
+ _, _ = w.WriteString("</code>")
+ return ast.WalkContinue, nil
+}
+
+func (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) {
+ colorContent := v.Text(reader.Source())
+ if matchColor(strings.ToLower(string(colorContent))) {
+ v.AppendChild(v, NewColorPreview(colorContent))
+ }
+}
diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go
new file mode 100644
index 0000000..6d48f34
--- /dev/null
+++ b/modules/markup/markdown/transform_heading.go
@@ -0,0 +1,32 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/text"
+)
+
+func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]markup.Header) {
+ for _, attr := range v.Attributes() {
+ if _, ok := attr.Value.([]byte); !ok {
+ v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
+ }
+ }
+ txt := v.Text(reader.Source())
+ header := markup.Header{
+ Text: util.UnsafeBytesToString(txt),
+ Level: v.Level,
+ }
+ if id, found := v.AttributeString("id"); found {
+ header.ID = util.UnsafeBytesToString(id.([]byte))
+ }
+ *tocList = append(*tocList, header)
+ g.applyElementDir(v)
+}
diff --git a/modules/markup/markdown/transform_image.go b/modules/markup/markdown/transform_image.go
new file mode 100644
index 0000000..b34a710
--- /dev/null
+++ b/modules/markup/markdown/transform_image.go
@@ -0,0 +1,65 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "strings"
+
+ "code.gitea.io/gitea/modules/markup"
+ giteautil "code.gitea.io/gitea/modules/util"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+func (g *ASTTransformer) transformImage(ctx *markup.RenderContext, v *ast.Image) {
+ // Images need two things:
+ //
+ // 1. Their src needs to munged to be a real value
+ // 2. If they're not wrapped with a link they need a link wrapper
+
+ // Check if the destination is a real link
+ if len(v.Destination) > 0 && !markup.IsLink(v.Destination) {
+ v.Destination = []byte(giteautil.URLJoin(
+ ctx.Links.ResolveMediaLink(ctx.IsWiki),
+ strings.TrimLeft(string(v.Destination), "/"),
+ ))
+ }
+
+ parent := v.Parent()
+ // Create a link around image only if parent is not already a link
+ if _, ok := parent.(*ast.Link); !ok && parent != nil {
+ next := v.NextSibling()
+
+ // Create a link wrapper
+ wrap := ast.NewLink()
+ wrap.Destination = v.Destination
+ wrap.Title = v.Title
+ wrap.SetAttributeString("target", []byte("_blank"))
+
+ // Duplicate the current image node
+ image := ast.NewImage(ast.NewLink())
+ image.Destination = v.Destination
+ image.Title = v.Title
+ for _, attr := range v.Attributes() {
+ image.SetAttribute(attr.Name, attr.Value)
+ }
+ for child := v.FirstChild(); child != nil; {
+ next := child.NextSibling()
+ image.AppendChild(image, child)
+ child = next
+ }
+
+ // Append our duplicate image to the wrapper link
+ wrap.AppendChild(wrap, image)
+
+ // Wire in the next sibling
+ wrap.SetNextSibling(next)
+
+ // Replace the current node with the wrapper link
+ parent.ReplaceChild(parent, v, wrap)
+
+ // But most importantly ensure the next sibling is still on the old image too
+ v.SetNextSibling(next)
+ }
+}
diff --git a/modules/markup/markdown/transform_link.go b/modules/markup/markdown/transform_link.go
new file mode 100644
index 0000000..e6f3836
--- /dev/null
+++ b/modules/markup/markdown/transform_link.go
@@ -0,0 +1,46 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "bytes"
+ "slices"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ giteautil "code.gitea.io/gitea/modules/util"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+func (g *ASTTransformer) transformLink(ctx *markup.RenderContext, v *ast.Link) {
+ // Links need their href to munged to be a real value
+ link := v.Destination
+
+ // Do not process the link if it's not a link, starts with an hashtag
+ // (indicating it's an anchor link), starts with `mailto:` or any of the
+ // custom markdown URLs.
+ processLink := len(link) > 0 && !markup.IsLink(link) &&
+ link[0] != '#' && !bytes.HasPrefix(link, byteMailto) &&
+ !slices.ContainsFunc(setting.Markdown.CustomURLSchemes, func(s string) bool {
+ return bytes.HasPrefix(link, []byte(s+":"))
+ })
+
+ if processLink {
+ var base string
+ if ctx.IsWiki {
+ base = ctx.Links.WikiLink()
+ } else if ctx.Links.HasBranchInfo() {
+ base = ctx.Links.SrcLink()
+ } else {
+ base = ctx.Links.Base
+ }
+
+ link = []byte(giteautil.URLJoin(base, string(link)))
+ }
+ if len(link) > 0 && link[0] == '#' {
+ link = []byte("#user-content-" + string(link)[1:])
+ }
+ v.Destination = link
+}
diff --git a/modules/markup/markdown/transform_list.go b/modules/markup/markdown/transform_list.go
new file mode 100644
index 0000000..b982fd4
--- /dev/null
+++ b/modules/markup/markdown/transform_list.go
@@ -0,0 +1,85 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "fmt"
+
+ "code.gitea.io/gitea/modules/markup"
+
+ "github.com/yuin/goldmark/ast"
+ east "github.com/yuin/goldmark/extension/ast"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*TaskCheckBoxListItem)
+ if entering {
+ if n.Attributes() != nil {
+ _, _ = w.WriteString("<li")
+ html.RenderAttributes(w, n, html.ListItemAttributeFilter)
+ _ = w.WriteByte('>')
+ } else {
+ _, _ = w.WriteString("<li>")
+ }
+ fmt.Fprintf(w, `<input type="checkbox" disabled="" data-source-position="%d"`, n.SourcePosition)
+ if n.IsChecked {
+ _, _ = w.WriteString(` checked=""`)
+ }
+ if r.XHTML {
+ _, _ = w.WriteString(` />`)
+ } else {
+ _ = w.WriteByte('>')
+ }
+ fc := n.FirstChild()
+ if fc != nil {
+ if _, ok := fc.(*ast.TextBlock); !ok {
+ _ = w.WriteByte('\n')
+ }
+ }
+ } else {
+ _, _ = w.WriteString("</li>\n")
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *HTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ return ast.WalkContinue, nil
+}
+
+func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc *RenderConfig) {
+ if v.HasChildren() {
+ children := make([]ast.Node, 0, v.ChildCount())
+ child := v.FirstChild()
+ for child != nil {
+ children = append(children, child)
+ child = child.NextSibling()
+ }
+ v.RemoveChildren(v)
+
+ for _, child := range children {
+ listItem := child.(*ast.ListItem)
+ if !child.HasChildren() || !child.FirstChild().HasChildren() {
+ v.AppendChild(v, child)
+ continue
+ }
+ taskCheckBox, ok := child.FirstChild().FirstChild().(*east.TaskCheckBox)
+ if !ok {
+ v.AppendChild(v, child)
+ continue
+ }
+ newChild := NewTaskCheckBoxListItem(listItem)
+ newChild.IsChecked = taskCheckBox.IsChecked
+ newChild.SetAttributeString("class", []byte("task-list-item"))
+ segments := newChild.FirstChild().Lines()
+ if segments.Len() > 0 {
+ segment := segments.At(0)
+ newChild.SourcePosition = rc.metaLength + segment.Start
+ }
+ v.AppendChild(v, newChild)
+ }
+ }
+ g.applyElementDir(v)
+}
diff --git a/modules/markup/mdstripper/mdstripper.go b/modules/markup/mdstripper/mdstripper.go
new file mode 100644
index 0000000..2a69d95
--- /dev/null
+++ b/modules/markup/mdstripper/mdstripper.go
@@ -0,0 +1,199 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mdstripper
+
+import (
+ "bytes"
+ "io"
+ "net/url"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup/common"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+)
+
+var (
+ giteaHostInit sync.Once
+ giteaHost *url.URL
+)
+
+type stripRenderer struct {
+ localhost *url.URL
+ links []string
+ empty bool
+}
+
+func (r *stripRenderer) Render(w io.Writer, source []byte, doc ast.Node) error {
+ return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ switch v := n.(type) {
+ case *ast.Text:
+ if !v.IsRaw() {
+ _, prevSibIsText := n.PreviousSibling().(*ast.Text)
+ coalesce := prevSibIsText
+ r.processString(
+ w,
+ v.Text(source),
+ coalesce)
+ if v.SoftLineBreak() {
+ r.doubleSpace(w)
+ }
+ }
+ return ast.WalkContinue, nil
+ case *ast.Link:
+ r.processLink(v.Destination)
+ return ast.WalkSkipChildren, nil
+ case *ast.AutoLink:
+ // This could be a reference to an issue or pull - if so convert it
+ r.processAutoLink(w, v.URL(source))
+ return ast.WalkSkipChildren, nil
+ }
+ return ast.WalkContinue, nil
+ })
+}
+
+func (r *stripRenderer) doubleSpace(w io.Writer) {
+ if !r.empty {
+ _, _ = w.Write([]byte{'\n'})
+ }
+}
+
+func (r *stripRenderer) processString(w io.Writer, text []byte, coalesce bool) {
+ // Always break-up words
+ if !coalesce {
+ r.doubleSpace(w)
+ }
+ _, _ = w.Write(text)
+ r.empty = false
+}
+
+// ProcessAutoLinks to detect and handle links to issues and pulls
+func (r *stripRenderer) processAutoLink(w io.Writer, link []byte) {
+ linkStr := string(link)
+ u, err := url.Parse(linkStr)
+ if err != nil {
+ // Process out of band
+ r.links = append(r.links, linkStr)
+ return
+ }
+
+ // Note: we're not attempting to match the URL scheme (http/https)
+ host := strings.ToLower(u.Host)
+ if host != "" && host != strings.ToLower(r.localhost.Host) {
+ // Process out of band
+ r.links = append(r.links, linkStr)
+ return
+ }
+
+ // We want: /user/repo/issues/3
+ parts := strings.Split(strings.TrimPrefix(u.EscapedPath(), r.localhost.EscapedPath()), "/")
+ if len(parts) != 5 || parts[0] != "" {
+ // Process out of band
+ r.links = append(r.links, linkStr)
+ return
+ }
+
+ var sep string
+ if parts[3] == "issues" {
+ sep = "#"
+ } else if parts[3] == "pulls" {
+ sep = "!"
+ } else {
+ // Process out of band
+ r.links = append(r.links, linkStr)
+ return
+ }
+
+ _, _ = w.Write([]byte(parts[1]))
+ _, _ = w.Write([]byte("/"))
+ _, _ = w.Write([]byte(parts[2]))
+ _, _ = w.Write([]byte(sep))
+ _, _ = w.Write([]byte(parts[4]))
+}
+
+func (r *stripRenderer) processLink(link []byte) {
+ // Links are processed out of band
+ r.links = append(r.links, string(link))
+}
+
+// GetLinks returns the list of link data collected while parsing
+func (r *stripRenderer) GetLinks() []string {
+ return r.links
+}
+
+// AddOptions adds given option to this renderer.
+func (r *stripRenderer) AddOptions(...renderer.Option) {
+ // no-op
+}
+
+// StripMarkdown parses markdown content by removing all markup and code blocks
+// in order to extract links and other references
+func StripMarkdown(rawBytes []byte) (string, []string) {
+ buf, links := StripMarkdownBytes(rawBytes)
+ return string(buf), links
+}
+
+var (
+ stripParser parser.Parser
+ once = sync.Once{}
+)
+
+// StripMarkdownBytes parses markdown content by removing all markup and code blocks
+// in order to extract links and other references
+func StripMarkdownBytes(rawBytes []byte) ([]byte, []string) {
+ once.Do(func() {
+ gdMarkdown := goldmark.New(
+ goldmark.WithExtensions(extension.Table,
+ extension.Strikethrough,
+ extension.TaskList,
+ extension.DefinitionList,
+ common.FootnoteExtension,
+ common.Linkify,
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAttribute(),
+ parser.WithAutoHeadingID(),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithUnsafe(),
+ ),
+ )
+ stripParser = gdMarkdown.Parser()
+ })
+ stripper := &stripRenderer{
+ localhost: getGiteaHost(),
+ links: make([]string, 0, 10),
+ empty: true,
+ }
+ reader := text.NewReader(rawBytes)
+ doc := stripParser.Parse(reader)
+ var buf bytes.Buffer
+ if err := stripper.Render(&buf, rawBytes, doc); err != nil {
+ log.Error("Unable to strip: %v", err)
+ }
+ return buf.Bytes(), stripper.GetLinks()
+}
+
+// getGiteaHostName returns a normalized string with the local host name, with no scheme or port information
+func getGiteaHost() *url.URL {
+ giteaHostInit.Do(func() {
+ var err error
+ if giteaHost, err = url.Parse(setting.AppURL); err != nil {
+ giteaHost = &url.URL{}
+ }
+ })
+ return giteaHost
+}
diff --git a/modules/markup/mdstripper/mdstripper_test.go b/modules/markup/mdstripper/mdstripper_test.go
new file mode 100644
index 0000000..ea34df0
--- /dev/null
+++ b/modules/markup/mdstripper/mdstripper_test.go
@@ -0,0 +1,85 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mdstripper
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMarkdownStripper(t *testing.T) {
+ type testItem struct {
+ markdown string
+ expectedText []string
+ expectedLinks []string
+ }
+
+ list := []testItem{
+ {
+ `
+## This is a title
+
+This is [one](link) to paradise.
+This **is emphasized**.
+This: should coalesce.
+
+` + "```" + `
+This is a code block.
+This should not appear in the output at all.
+` + "```" + `
+
+* Bullet 1
+* Bullet 2
+
+A HIDDEN ` + "`" + `GHOST` + "`" + ` IN THIS LINE.
+ `,
+ []string{
+ "This is a title",
+ "This is",
+ "to paradise.",
+ "This",
+ "is emphasized",
+ ".",
+ "This: should coalesce.",
+ "Bullet 1",
+ "Bullet 2",
+ "A HIDDEN",
+ "IN THIS LINE.",
+ },
+ []string{
+ "link",
+ },
+ },
+ {
+ "Simply closes: #29 yes",
+ []string{
+ "Simply closes: #29 yes",
+ },
+ []string{},
+ },
+ {
+ "Simply closes: !29 yes",
+ []string{
+ "Simply closes: !29 yes",
+ },
+ []string{},
+ },
+ }
+
+ for _, test := range list {
+ text, links := StripMarkdown([]byte(test.markdown))
+ rawlines := strings.Split(text, "\n")
+ lines := make([]string, 0, len(rawlines))
+ for _, line := range rawlines {
+ line := strings.TrimSpace(line)
+ if line != "" {
+ lines = append(lines, line)
+ }
+ }
+ assert.EqualValues(t, test.expectedText, lines)
+ assert.EqualValues(t, test.expectedLinks, links)
+ }
+}
diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go
new file mode 100644
index 0000000..391ee6c
--- /dev/null
+++ b/modules/markup/orgmode/orgmode.go
@@ -0,0 +1,196 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "fmt"
+ "html"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/highlight"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/niklasfasching/go-org/org"
+)
+
+func init() {
+ markup.RegisterRenderer(Renderer{})
+}
+
+// Renderer implements markup.Renderer for orgmode
+type Renderer struct{}
+
+var _ markup.PostProcessRenderer = (*Renderer)(nil)
+
+// Name implements markup.Renderer
+func (Renderer) Name() string {
+ return "orgmode"
+}
+
+// NeedPostProcess implements markup.PostProcessRenderer
+func (Renderer) NeedPostProcess() bool { return true }
+
+// Extensions implements markup.Renderer
+func (Renderer) Extensions() []string {
+ return []string{".org"}
+}
+
+// SanitizerRules implements markup.Renderer
+func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
+ return []setting.MarkupSanitizerRule{}
+}
+
+// Render renders orgmode rawbytes to HTML
+func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ htmlWriter := org.NewHTMLWriter()
+ htmlWriter.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error("Panic in HighlightCodeBlock: %v\n%s", err, log.Stack(2))
+ panic(err)
+ }
+ }()
+ var w strings.Builder
+ if _, err := w.WriteString(`<pre>`); err != nil {
+ return ""
+ }
+
+ lexer := lexers.Get(lang)
+ if lexer == nil && lang == "" {
+ lexer = lexers.Analyse(source)
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+ lang = strings.ToLower(lexer.Config().Name)
+ }
+
+ if lexer == nil {
+ // include language-x class as part of commonmark spec
+ if _, err := w.WriteString(`<code class="chroma language-` + lang + `">`); err != nil {
+ return ""
+ }
+ if _, err := w.WriteString(html.EscapeString(source)); err != nil {
+ return ""
+ }
+ } else {
+ // include language-x class as part of commonmark spec
+ if _, err := w.WriteString(`<code class="chroma language-` + lang + `">`); err != nil {
+ return ""
+ }
+ lexer = chroma.Coalesce(lexer)
+
+ if _, err := w.WriteString(string(highlight.CodeFromLexer(lexer, source))); err != nil {
+ return ""
+ }
+ }
+
+ if _, err := w.WriteString("</code></pre>"); err != nil {
+ return ""
+ }
+
+ return w.String()
+ }
+
+ w := &Writer{
+ HTMLWriter: htmlWriter,
+ Ctx: ctx,
+ }
+
+ htmlWriter.ExtendingWriter = w
+
+ res, err := org.New().Silent().Parse(input, "").Write(w)
+ if err != nil {
+ return fmt.Errorf("orgmode.Render failed: %w", err)
+ }
+ _, err = io.Copy(output, strings.NewReader(res))
+ return err
+}
+
+// RenderString renders orgmode string to HTML string
+func RenderString(ctx *markup.RenderContext, content string) (string, error) {
+ var buf strings.Builder
+ if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+// Render renders orgmode string to HTML string
+func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ return Render(ctx, input, output)
+}
+
+// Writer implements org.Writer
+type Writer struct {
+ *org.HTMLWriter
+ Ctx *markup.RenderContext
+}
+
+const mailto = "mailto:"
+
+func (r *Writer) resolveLink(node org.Node) string {
+ l, ok := node.(org.RegularLink)
+ if !ok {
+ l = org.RegularLink{URL: strings.TrimPrefix(org.String(node), "file:")}
+ }
+
+ link := html.EscapeString(l.URL)
+ if l.Protocol == "file" {
+ link = link[len("file:"):]
+ }
+ if len(link) > 0 && !markup.IsLinkStr(link) &&
+ link[0] != '#' && !strings.HasPrefix(link, mailto) {
+ var base string
+ if r.Ctx.IsWiki {
+ base = r.Ctx.Links.WikiLink()
+ } else if r.Ctx.Links.HasBranchInfo() {
+ base = r.Ctx.Links.SrcLink()
+ } else {
+ base = r.Ctx.Links.Base
+ }
+
+ switch l.Kind() {
+ case "image", "video":
+ base = r.Ctx.Links.ResolveMediaLink(r.Ctx.IsWiki)
+ }
+
+ link = util.URLJoin(base, link)
+ }
+ return link
+}
+
+// WriteRegularLink renders images, links or videos
+func (r *Writer) WriteRegularLink(l org.RegularLink) {
+ link := r.resolveLink(l)
+
+ // Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
+ switch l.Kind() {
+ case "image":
+ if l.Description == nil {
+ fmt.Fprintf(r, `<img src="%s" alt="%s" />`, link, link)
+ } else {
+ imageSrc := r.resolveLink(l.Description[0])
+ fmt.Fprintf(r, `<a href="%s"><img src="%s" alt="%s" /></a>`, link, imageSrc, imageSrc)
+ }
+ case "video":
+ if l.Description == nil {
+ fmt.Fprintf(r, `<video src="%s">%s</video>`, link, link)
+ } else {
+ videoSrc := r.resolveLink(l.Description[0])
+ fmt.Fprintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
+ }
+ default:
+ description := link
+ if l.Description != nil {
+ description = r.WriteNodesAsString(l.Description...)
+ }
+ fmt.Fprintf(r, `<a href="%s">%s</a>`, link, description)
+ }
+}
diff --git a/modules/markup/orgmode/orgmode_test.go b/modules/markup/orgmode/orgmode_test.go
new file mode 100644
index 0000000..f41d86a
--- /dev/null
+++ b/modules/markup/orgmode/orgmode_test.go
@@ -0,0 +1,160 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ AppURL = "http://localhost:3000/"
+ Repo = "gogits/gogs"
+ AppSubURL = AppURL + Repo + "/"
+)
+
+func TestRender_StandardLinks(t *testing.T) {
+ setting.AppURL = AppURL
+ setting.AppSubURL = AppSubURL
+
+ test := func(input, expected string) {
+ buffer, err := RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: setting.AppSubURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ // No BranchPath or TreePath set.
+ test("[[file:comfy][comfy]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/comfy">comfy</a></p>`)
+
+ test("[[https://google.com/]]",
+ `<p><a href="https://google.com/">https://google.com/</a></p>`)
+
+ lnk := util.URLJoin(AppSubURL, "WikiPage")
+ test("[[WikiPage][WikiPage]]",
+ `<p><a href="`+lnk+`">WikiPage</a></p>`)
+}
+
+func TestRender_BaseLinks(t *testing.T) {
+ setting.AppURL = AppURL
+ setting.AppSubURL = AppSubURL
+
+ testBranch := func(input, expected string) {
+ buffer, err := RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: setting.AppSubURL,
+ BranchPath: "branch/main",
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ testBranchTree := func(input, expected string) {
+ buffer, err := RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: setting.AppSubURL,
+ BranchPath: "branch/main",
+ TreePath: "deep/nested/folder",
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ testBranch("[[file:comfy][comfy]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/comfy">comfy</a></p>`)
+ testBranchTree("[[file:comfy][comfy]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/deep/nested/folder/comfy">comfy</a></p>`)
+
+ testBranch("[[file:./src][./src/]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/src">./src/</a></p>`)
+ testBranchTree("[[file:./src][./src/]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/deep/nested/folder/src">./src/</a></p>`)
+}
+
+func TestRender_Media(t *testing.T) {
+ setting.AppURL = AppURL
+ setting.AppSubURL = AppSubURL
+
+ test := func(input, expected string) {
+ buffer, err := RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ Links: markup.Links{
+ Base: setting.AppSubURL,
+ },
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ url := "../../.images/src/02/train.jpg"
+ result := util.URLJoin(AppSubURL, url)
+
+ test("[[file:"+url+"]]",
+ `<p><img src="`+result+`" alt="`+result+`" /></p>`)
+
+ // With description.
+ test("[[https://example.com][https://example.com/example.svg]]",
+ `<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg" /></a></p>`)
+ test("[[https://example.com][pre https://example.com/example.svg post]]",
+ `<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg" /> post</a></p>`)
+ test("[[https://example.com][https://example.com/example.mp4]]",
+ `<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
+ test("[[https://example.com][pre https://example.com/example.mp4 post]]",
+ `<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
+
+ // Without description.
+ test("[[https://example.com/example.svg]]",
+ `<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg" /></p>`)
+ test("[[https://example.com/example.mp4]]",
+ `<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
+
+ // Text description.
+ test("[[file:./lem-post.png][file:./lem-post.png]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/lem-post.png"><img src="http://localhost:3000/gogits/gogs/lem-post.png" alt="http://localhost:3000/gogits/gogs/lem-post.png" /></a></p>`)
+ test("[[file:./lem-post.mp4][file:./lem-post.mp4]]",
+ `<p><a href="http://localhost:3000/gogits/gogs/lem-post.mp4"><video src="http://localhost:3000/gogits/gogs/lem-post.mp4">http://localhost:3000/gogits/gogs/lem-post.mp4</video></a></p>`)
+}
+
+func TestRender_Source(t *testing.T) {
+ setting.AppURL = AppURL
+ setting.AppSubURL = AppSubURL
+
+ test := func(input, expected string) {
+ buffer, err := RenderString(&markup.RenderContext{
+ Ctx: git.DefaultContext,
+ }, input)
+ require.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ }
+
+ test(`#+begin_src go
+// HelloWorld prints "Hello World"
+func HelloWorld() {
+ fmt.Println("Hello World")
+}
+#+end_src
+`, `<div class="src src-go">
+<pre><code class="chroma language-go"><span class="c1">// HelloWorld prints &#34;Hello World&#34;
+</span><span class="c1"></span><span class="kd">func</span> <span class="nf">HelloWorld</span><span class="p">()</span> <span class="p">{</span>
+ <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;Hello World&#34;</span><span class="p">)</span>
+<span class="p">}</span></code></pre>
+</div>`)
+}
diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go
new file mode 100644
index 0000000..2137302
--- /dev/null
+++ b/modules/markup/renderer.go
@@ -0,0 +1,393 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+type RenderMetaMode string
+
+const (
+ RenderMetaAsDetails RenderMetaMode = "details" // default
+ RenderMetaAsNone RenderMetaMode = "none"
+ RenderMetaAsTable RenderMetaMode = "table"
+)
+
+type ProcessorHelper struct {
+ IsUsernameMentionable func(ctx context.Context, username string) bool
+ GetRepoFileBlob func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error)
+
+ ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
+}
+
+var DefaultProcessorHelper ProcessorHelper
+
+// Init initialize regexps for markdown parsing
+func Init(ph *ProcessorHelper) {
+ if ph != nil {
+ DefaultProcessorHelper = *ph
+ }
+
+ NewSanitizer()
+ if len(setting.Markdown.CustomURLSchemes) > 0 {
+ CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
+ }
+
+ // since setting maybe changed extensions, this will reload all renderer extensions mapping
+ extRenderers = make(map[string]Renderer)
+ for _, renderer := range renderers {
+ for _, ext := range renderer.Extensions() {
+ extRenderers[strings.ToLower(ext)] = renderer
+ }
+ }
+}
+
+// Header holds the data about a header.
+type Header struct {
+ Level int
+ Text string
+ ID string
+}
+
+// RenderContext represents a render context
+type RenderContext struct {
+ Ctx context.Context
+ RelativePath string // relative path from tree root of the branch
+ Type string
+ IsWiki bool
+ Links Links
+ Metas map[string]string
+ DefaultLink string
+ GitRepo *git.Repository
+ ShaExistCache map[string]bool
+ cancelFn func()
+ SidebarTocNode ast.Node
+ InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
+}
+
+type Links struct {
+ AbsolutePrefix bool
+ Base string
+ BranchPath string
+ TreePath string
+}
+
+func (l *Links) Prefix() string {
+ if l.AbsolutePrefix {
+ return setting.AppURL
+ }
+ return setting.AppSubURL
+}
+
+func (l *Links) HasBranchInfo() bool {
+ return l.BranchPath != ""
+}
+
+func (l *Links) SrcLink() string {
+ return util.URLJoin(l.Base, "src", l.BranchPath, l.TreePath)
+}
+
+func (l *Links) MediaLink() string {
+ return util.URLJoin(l.Base, "media", l.BranchPath, l.TreePath)
+}
+
+func (l *Links) RawLink() string {
+ return util.URLJoin(l.Base, "raw", l.BranchPath, l.TreePath)
+}
+
+func (l *Links) WikiLink() string {
+ return util.URLJoin(l.Base, "wiki")
+}
+
+func (l *Links) WikiRawLink() string {
+ return util.URLJoin(l.Base, "wiki/raw")
+}
+
+func (l *Links) ResolveMediaLink(isWiki bool) string {
+ if isWiki {
+ return l.WikiRawLink()
+ } else if l.HasBranchInfo() {
+ return l.MediaLink()
+ }
+ return l.Base
+}
+
+// Cancel runs any cleanup functions that have been registered for this Ctx
+func (ctx *RenderContext) Cancel() {
+ if ctx == nil {
+ return
+ }
+ ctx.ShaExistCache = map[string]bool{}
+ if ctx.cancelFn == nil {
+ return
+ }
+ ctx.cancelFn()
+}
+
+// AddCancel adds the provided fn as a Cleanup for this Ctx
+func (ctx *RenderContext) AddCancel(fn func()) {
+ if ctx == nil {
+ return
+ }
+ oldCancelFn := ctx.cancelFn
+ if oldCancelFn == nil {
+ ctx.cancelFn = fn
+ return
+ }
+ ctx.cancelFn = func() {
+ defer oldCancelFn()
+ fn()
+ }
+}
+
+// Renderer defines an interface for rendering markup file to HTML
+type Renderer interface {
+ Name() string // markup format name
+ Extensions() []string
+ SanitizerRules() []setting.MarkupSanitizerRule
+ Render(ctx *RenderContext, input io.Reader, output io.Writer) error
+}
+
+// PostProcessRenderer defines an interface for renderers who need post process
+type PostProcessRenderer interface {
+ NeedPostProcess() bool
+}
+
+// PostProcessRenderer defines an interface for external renderers
+type ExternalRenderer interface {
+ // SanitizerDisabled disabled sanitize if return true
+ SanitizerDisabled() bool
+
+ // DisplayInIFrame represents whether render the content with an iframe
+ DisplayInIFrame() bool
+}
+
+// RendererContentDetector detects if the content can be rendered
+// by specified renderer
+type RendererContentDetector interface {
+ CanRender(filename string, input io.Reader) bool
+}
+
+var (
+ extRenderers = make(map[string]Renderer)
+ renderers = make(map[string]Renderer)
+)
+
+// RegisterRenderer registers a new markup file renderer
+func RegisterRenderer(renderer Renderer) {
+ renderers[renderer.Name()] = renderer
+ for _, ext := range renderer.Extensions() {
+ extRenderers[strings.ToLower(ext)] = renderer
+ }
+}
+
+// GetRendererByFileName get renderer by filename
+func GetRendererByFileName(filename string) Renderer {
+ extension := strings.ToLower(filepath.Ext(filename))
+ return extRenderers[extension]
+}
+
+// GetRendererByType returns a renderer according type
+func GetRendererByType(tp string) Renderer {
+ return renderers[tp]
+}
+
+// DetectRendererType detects the markup type of the content
+func DetectRendererType(filename string, input io.Reader) string {
+ buf, err := io.ReadAll(input)
+ if err != nil {
+ return ""
+ }
+ for _, renderer := range renderers {
+ if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, bytes.NewReader(buf)) {
+ return renderer.Name()
+ }
+ }
+ return ""
+}
+
+// Render renders markup file to HTML with all specific handling stuff.
+func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
+ if ctx.Type != "" {
+ return renderByType(ctx, input, output)
+ } else if ctx.RelativePath != "" {
+ return renderFile(ctx, input, output)
+ }
+ return errors.New("Render options both filename and type missing")
+}
+
+// RenderString renders Markup string to HTML with all specific handling stuff and return string
+func RenderString(ctx *RenderContext, content string) (string, error) {
+ var buf strings.Builder
+ if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+type nopCloser struct {
+ io.Writer
+}
+
+func (nopCloser) Close() error { return nil }
+
+func renderIFrame(ctx *RenderContext, output io.Writer) error {
+ // set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
+ // at the moment, only "allow-scripts" is allowed for sandbox mode.
+ // "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
+ // TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read
+ _, err := io.WriteString(output, fmt.Sprintf(`
+<iframe src="%s/%s/%s/render/%s/%s"
+name="giteaExternalRender"
+onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
+width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
+sandbox="allow-scripts"
+></iframe>`,
+ setting.AppSubURL,
+ url.PathEscape(ctx.Metas["user"]),
+ url.PathEscape(ctx.Metas["repo"]),
+ ctx.Metas["BranchNameSubURL"],
+ url.PathEscape(ctx.RelativePath),
+ ))
+ return err
+}
+
+func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
+ var wg sync.WaitGroup
+ var err error
+ pr, pw := io.Pipe()
+ defer func() {
+ _ = pr.Close()
+ _ = pw.Close()
+ }()
+
+ var pr2 io.ReadCloser
+ var pw2 io.WriteCloser
+
+ var sanitizerDisabled bool
+ if r, ok := renderer.(ExternalRenderer); ok {
+ sanitizerDisabled = r.SanitizerDisabled()
+ }
+
+ if !sanitizerDisabled {
+ pr2, pw2 = io.Pipe()
+ defer func() {
+ _ = pr2.Close()
+ _ = pw2.Close()
+ }()
+
+ wg.Add(1)
+ go func() {
+ err = SanitizeReader(pr2, renderer.Name(), output)
+ _ = pr2.Close()
+ wg.Done()
+ }()
+ } else {
+ pw2 = nopCloser{output}
+ }
+
+ wg.Add(1)
+ go func() {
+ if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
+ err = PostProcess(ctx, pr, pw2)
+ } else {
+ _, err = io.Copy(pw2, pr)
+ }
+ _ = pr.Close()
+ _ = pw2.Close()
+ wg.Done()
+ }()
+
+ if err1 := renderer.Render(ctx, input, pw); err1 != nil {
+ return err1
+ }
+ _ = pw.Close()
+
+ wg.Wait()
+ return err
+}
+
+// ErrUnsupportedRenderType represents
+type ErrUnsupportedRenderType struct {
+ Type string
+}
+
+func (err ErrUnsupportedRenderType) Error() string {
+ return fmt.Sprintf("Unsupported render type: %s", err.Type)
+}
+
+func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
+ if renderer, ok := renderers[ctx.Type]; ok {
+ return render(ctx, renderer, input, output)
+ }
+ return ErrUnsupportedRenderType{ctx.Type}
+}
+
+// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
+type ErrUnsupportedRenderExtension struct {
+ Extension string
+}
+
+func IsErrUnsupportedRenderExtension(err error) bool {
+ _, ok := err.(ErrUnsupportedRenderExtension)
+ return ok
+}
+
+func (err ErrUnsupportedRenderExtension) Error() string {
+ return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
+}
+
+func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
+ extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
+ if renderer, ok := extRenderers[extension]; ok {
+ if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
+ if !ctx.InStandalonePage {
+ // for an external render, it could only output its content in a standalone page
+ // otherwise, a <iframe> should be outputted to embed the external rendered page
+ return renderIFrame(ctx, output)
+ }
+ }
+ return render(ctx, renderer, input, output)
+ }
+ return ErrUnsupportedRenderExtension{extension}
+}
+
+// Type returns if markup format via the filename
+func Type(filename string) string {
+ if parser := GetRendererByFileName(filename); parser != nil {
+ return parser.Name()
+ }
+ return ""
+}
+
+// IsMarkupFile reports whether file is a markup type file
+func IsMarkupFile(name, markup string) bool {
+ if parser := GetRendererByFileName(name); parser != nil {
+ return parser.Name() == markup
+ }
+ return false
+}
+
+func PreviewableExtensions() []string {
+ extensions := make([]string, 0, len(extRenderers))
+ for extension := range extRenderers {
+ extensions = append(extensions, extension)
+ }
+ return extensions
+}
diff --git a/modules/markup/renderer_test.go b/modules/markup/renderer_test.go
new file mode 100644
index 0000000..0791081
--- /dev/null
+++ b/modules/markup/renderer_test.go
@@ -0,0 +1,4 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup_test
diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go
new file mode 100644
index 0000000..ddc218c
--- /dev/null
+++ b/modules/markup/sanitizer.go
@@ -0,0 +1,235 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "io"
+ "net/url"
+ "regexp"
+ "sync"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/microcosm-cc/bluemonday"
+)
+
+// Sanitizer is a protection wrapper of *bluemonday.Policy which does not allow
+// any modification to the underlying policies once it's been created.
+type Sanitizer struct {
+ defaultPolicy *bluemonday.Policy
+ descriptionPolicy *bluemonday.Policy
+ rendererPolicies map[string]*bluemonday.Policy
+ init sync.Once
+}
+
+var (
+ sanitizer = &Sanitizer{}
+ allowAllRegex = regexp.MustCompile(".+")
+)
+
+// NewSanitizer initializes sanitizer with allowed attributes based on settings.
+// Multiple calls to this function will only create one instance of Sanitizer during
+// entire application lifecycle.
+func NewSanitizer() {
+ sanitizer.init.Do(func() {
+ InitializeSanitizer()
+ })
+}
+
+// InitializeSanitizer (re)initializes the current sanitizer to account for changes in settings
+func InitializeSanitizer() {
+ sanitizer.rendererPolicies = map[string]*bluemonday.Policy{}
+ sanitizer.defaultPolicy = createDefaultPolicy()
+ sanitizer.descriptionPolicy = createRepoDescriptionPolicy()
+
+ for name, renderer := range renderers {
+ sanitizerRules := renderer.SanitizerRules()
+ if len(sanitizerRules) > 0 {
+ policy := createDefaultPolicy()
+ addSanitizerRules(policy, sanitizerRules)
+ sanitizer.rendererPolicies[name] = policy
+ }
+ }
+}
+
+func createDefaultPolicy() *bluemonday.Policy {
+ policy := bluemonday.UGCPolicy()
+
+ // For JS code copy and Mermaid loading state
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
+
+ // For color preview
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
+
+ // For attention
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-title$`)).OnElements("p")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-header attention-\w+$`)).OnElements("blockquote")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-\w+$`)).OnElements("strong")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-icon attention-\w+ svg octicon-[\w-]+$`)).OnElements("svg")
+ policy.AllowAttrs("viewBox", "width", "height", "aria-hidden").OnElements("svg")
+ policy.AllowAttrs("fill-rule", "d").OnElements("path")
+
+ // For Chroma markdown plugin
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
+
+ // Checkboxes
+ policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
+ policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
+
+ // Custom URL-Schemes
+ if len(setting.Markdown.CustomURLSchemes) > 0 {
+ policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...)
+ } else {
+ policy.AllowURLSchemesMatching(allowAllRegex)
+
+ // Even if every scheme is allowed, these three are blocked for security reasons
+ disallowScheme := func(*url.URL) bool {
+ return false
+ }
+ policy.AllowURLSchemeWithCustomPolicy("javascript", disallowScheme)
+ policy.AllowURLSchemeWithCustomPolicy("vbscript", disallowScheme)
+ policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme)
+ }
+
+ // Allow classes for anchors
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`ref-issue( ref-external-issue)?`)).OnElements("a")
+
+ // Allow classes for task lists
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`task-list-item`)).OnElements("li")
+
+ // Allow classes for org mode list item status.
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li")
+
+ // Allow icons
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i")
+
+ // Allow classes for emojis
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`emoji`)).OnElements("img")
+
+ // Allow icons, emojis, chroma syntax and keyword markup on span
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(language-math display)|(language-math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span")
+
+ // Allow 'color' and 'background-color' properties for the style attribute on text elements and table cells.
+ policy.AllowStyles("color", "background-color").OnElements("span", "p", "th", "td")
+
+ // Allow classes for file preview links...
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^(lines-num|lines-code chroma)$")).OnElements("td")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^code-inner$")).OnElements("code")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview-box$")).OnElements("div")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui table$")).OnElements("div")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^header$")).OnElements("div")
+ policy.AllowAttrs("data-line-number").Matching(regexp.MustCompile("^[0-9]+$")).OnElements("span")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^text small grey$")).OnElements("span")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview*")).OnElements("table")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^lines-escape$")).OnElements("td")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^toggle-escape-button btn interact-bg$")).OnElements("button")
+ policy.AllowAttrs("title").OnElements("button")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^ambiguous-code-point$")).OnElements("span")
+ policy.AllowAttrs("data-tooltip-content").OnElements("span")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("muted|(text black)")).OnElements("a")
+ policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui warning message tw-text-left$")).OnElements("div")
+
+ // Allow generally safe attributes
+ generalSafeAttrs := []string{
+ "abbr", "accept", "accept-charset",
+ "accesskey", "action", "align", "alt",
+ "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby",
+ "axis", "border", "cellpadding", "cellspacing", "char",
+ "charoff", "charset", "checked",
+ "clear", "cols", "colspan", "color",
+ "compact", "coords", "datetime", "dir",
+ "disabled", "enctype", "for", "frame",
+ "headers", "height", "hreflang",
+ "hspace", "ismap", "label", "lang",
+ "maxlength", "media", "method",
+ "multiple", "name", "nohref", "noshade",
+ "nowrap", "open", "prompt", "readonly", "rel", "rev",
+ "rows", "rowspan", "rules", "scope",
+ "selected", "shape", "size", "span",
+ "start", "summary", "tabindex", "target",
+ "title", "type", "usemap", "valign", "value",
+ "vspace", "width", "itemprop",
+ }
+
+ generalSafeElements := []string{
+ "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
+ "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
+ "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
+ "details", "caption", "figure", "figcaption",
+ "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
+ }
+
+ policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
+
+ policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
+
+ policy.AllowAttrs("itemscope", "itemtype").OnElements("div")
+
+ // FIXME: Need to handle longdesc in img but there is no easy way to do it
+
+ // Custom keyword markup
+ addSanitizerRules(policy, setting.ExternalSanitizerRules)
+
+ return policy
+}
+
+// createRepoDescriptionPolicy returns a minimal more strict policy that is used for
+// repository descriptions.
+func createRepoDescriptionPolicy() *bluemonday.Policy {
+ policy := bluemonday.NewPolicy()
+ policy.AllowStandardURLs()
+
+ // Allow italics and bold.
+ policy.AllowElements("i", "b", "em", "strong")
+
+ // Allow code.
+ policy.AllowElements("code")
+
+ // Allow links
+ policy.AllowAttrs("href", "target", "rel").OnElements("a")
+
+ // Allow classes for emojis
+ policy.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("img", "span")
+ policy.AllowAttrs("aria-label").OnElements("span")
+
+ return policy
+}
+
+func addSanitizerRules(policy *bluemonday.Policy, rules []setting.MarkupSanitizerRule) {
+ for _, rule := range rules {
+ if rule.AllowDataURIImages {
+ policy.AllowDataURIImages()
+ }
+ if rule.Element != "" {
+ if rule.Regexp != nil {
+ policy.AllowAttrs(rule.AllowAttr).Matching(rule.Regexp).OnElements(rule.Element)
+ } else {
+ policy.AllowAttrs(rule.AllowAttr).OnElements(rule.Element)
+ }
+ }
+ }
+}
+
+// SanitizeDescription sanitizes the HTML generated for a repository description.
+func SanitizeDescription(s string) string {
+ NewSanitizer()
+ return sanitizer.descriptionPolicy.Sanitize(s)
+}
+
+// Sanitize takes a string that contains a HTML fragment or document and applies policy whitelist.
+func Sanitize(s string) string {
+ NewSanitizer()
+ return sanitizer.defaultPolicy.Sanitize(s)
+}
+
+// SanitizeReader sanitizes a Reader
+func SanitizeReader(r io.Reader, renderer string, w io.Writer) error {
+ NewSanitizer()
+ policy, exist := sanitizer.rendererPolicies[renderer]
+ if !exist {
+ policy = sanitizer.defaultPolicy
+ }
+ return policy.SanitizeReaderToWriter(r, w)
+}
diff --git a/modules/markup/sanitizer_test.go b/modules/markup/sanitizer_test.go
new file mode 100644
index 0000000..4441a41
--- /dev/null
+++ b/modules/markup/sanitizer_test.go
@@ -0,0 +1,110 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Sanitizer(t *testing.T) {
+ NewSanitizer()
+ testCases := []string{
+ // Regular
+ `<a onblur="alert(secret)" href="http://www.google.com">Google</a>`, `<a href="http://www.google.com" rel="nofollow">Google</a>`,
+
+ // Code highlighting class
+ `<code class="random string"></code>`, `<code></code>`,
+ `<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`,
+ `<code class="language-go"></code>`, `<code class="language-go"></code>`,
+
+ // Input checkbox
+ `<input type="hidden">`, ``,
+ `<input type="checkbox">`, `<input type="checkbox">`,
+ `<input checked disabled autofocus>`, `<input checked="" disabled="">`,
+
+ // Code highlight injection
+ `<code class="language-random&#32;ui&#32;tab&#32;active&#32;menu&#32;attached&#32;animating&#32;sidebar&#32;following&#32;bar&#32;center"></code>`, `<code></code>`,
+ `<code class="language-lol&#32;ui&#32;tab&#32;active&#32;menu&#32;attached&#32;animating&#32;sidebar&#32;following&#32;bar&#32;center">
+<code class="language-lol&#32;ui&#32;container&#32;input&#32;huge&#32;basic&#32;segment&#32;center">&nbsp;</code>
+<img src="https://try.gogs.io/img/favicon.png" width="200" height="200">
+<code class="language-lol&#32;ui&#32;container&#32;input&#32;massive&#32;basic&#32;segment">Hello there! Something has gone wrong, we are working on it.</code>
+<code class="language-lol&#32;ui&#32;container&#32;input&#32;huge&#32;basic&#32;segment">In the meantime, play a game with us at&nbsp;<a href="http://example.com/">example.com</a>.</code>
+</code>`, "<code>\n<code>\u00a0</code>\n<img src=\"https://try.gogs.io/img/favicon.png\" width=\"200\" height=\"200\">\n<code>Hello there! Something has gone wrong, we are working on it.</code>\n<code>In the meantime, play a game with us at\u00a0<a href=\"http://example.com/\" rel=\"nofollow\">example.com</a>.</code>\n</code>",
+
+ // <kbd> tags
+ `<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`,
+ `<i class="dropdown icon">NAUGHTY</i>`, `<i>NAUGHTY</i>`,
+ `<i class="icon dropdown"></i>`, `<i class="icon dropdown"></i>`,
+ `<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`,
+ `<span class="emoji dropdown">NAUGHTY</span>`, `<span>NAUGHTY</span>`,
+ `<span class="emoji">contents</span>`, `<span class="emoji">contents</span>`,
+
+ // Color property
+ `<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`,
+ `<p style="color: red; background-color: red">Hello World</p>`, `<p style="color: red; background-color: red">Hello World</p>`,
+ `<table><tr><th style="color: red">TH1</th><th style="background-color: red">TH2</th><th style="color: red; background-color: red">TH3</th></tr><tr><td style="color: red">TD1</td><td style="background-color: red">TD2</td><td style="color: red; background-color: red">TD3</td></tr></table>`, `<table><tr><th style="color: red">TH1</th><th style="background-color: red">TH2</th><th style="color: red; background-color: red">TH3</th></tr><tr><td style="color: red">TD1</td><td style="background-color: red">TD2</td><td style="color: red; background-color: red">TD3</td></tr></table>`,
+ `<code style="color: red">Hello World</code>`, `<code>Hello World</code>`,
+ `<code style="background-color: red">Hello World</code>`, `<code>Hello World</code>`,
+ `<span style="bad-color: red">Hello World</span>`, `<span>Hello World</span>`,
+ `<p style="bad-color: red">Hello World</p>`, `<p>Hello World</p>`,
+ `<code style="bad-color: red">Hello World</code>`, `<code>Hello World</code>`,
+
+ // Org mode status of list items.
+ `<li class="checked"></li>`, `<li class="checked"></li>`,
+ `<li class="unchecked"></li>`, `<li class="unchecked"></li>`,
+ `<li class="indeterminate"></li>`, `<li class="indeterminate"></li>`,
+
+ // URLs
+ `<a href="cbthunderlink://somebase64string)">my custom URL scheme</a>`, `<a href="cbthunderlink://somebase64string)" rel="nofollow">my custom URL scheme</a>`,
+ `<a href="matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join">my custom URL scheme</a>`, `<a href="matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join" rel="nofollow">my custom URL scheme</a>`,
+
+ // Disallow dangerous url schemes
+ `<a href="javascript:alert('xss')">bad</a>`, `bad`,
+ `<a href="vbscript:no">bad</a>`, `bad`,
+ `<a href="data:1234">bad</a>`, `bad`,
+ }
+
+ for i := 0; i < len(testCases); i += 2 {
+ assert.Equal(t, testCases[i+1], Sanitize(testCases[i]))
+ }
+}
+
+func TestDescriptionSanitizer(t *testing.T) {
+ NewSanitizer()
+
+ testCases := []string{
+ `<h1>Title</h1>`, `Title`,
+ `<img src='img.png' alt='image'>`, ``,
+ `<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`, `<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`,
+ `<span style="color: red">Hello World</span>`, `<span>Hello World</span>`,
+ `<br>`, ``,
+ `<a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>`, `<a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">https://example.com</a>`,
+ `<mark>Important!</mark>`, `Important!`,
+ `<details>Click me! <summary>Nothing to see here.</summary></details>`, `Click me! Nothing to see here.`,
+ `<input type="hidden">`, ``,
+ `<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`, `<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`,
+ `Provides alternative <code>wg(8)</code> tool`, `Provides alternative <code>wg(8)</code> tool`,
+ `<a href="javascript:alert('xss')">Click me</a>.`, `Click me.`,
+ `<a href="data:text/html,<script>alert('xss')</script>">Click me</a>.`, `Click me.`,
+ `<a href="vbscript:msgbox("xss")">Click me</a>.`, `Click me.`,
+ }
+
+ for i := 0; i < len(testCases); i += 2 {
+ assert.Equal(t, testCases[i+1], SanitizeDescription(testCases[i]))
+ }
+}
+
+func TestSanitizeNonEscape(t *testing.T) {
+ descStr := "<scrÄ°pt>&lt;script&gt;alert(document.domain)&lt;/script&gt;</scrÄ°pt>"
+
+ output := template.HTML(Sanitize(descStr))
+ if strings.Contains(string(output), "<script>") {
+ t.Errorf("un-escaped <script> in output: %q", output)
+ }
+}
diff --git a/modules/markup/tests/repo/repo1_filepreview/HEAD b/modules/markup/tests/repo/repo1_filepreview/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/markup/tests/repo/repo1_filepreview/config b/modules/markup/tests/repo/repo1_filepreview/config
new file mode 100644
index 0000000..42cc799
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+[remote "origin"]
+ url = /home/mai/projects/codeark/forgejo/forgejo/modules/markup/tests/repo/repo1_filepreview/../../__test_repo
diff --git a/modules/markup/tests/repo/repo1_filepreview/description b/modules/markup/tests/repo/repo1_filepreview/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/markup/tests/repo/repo1_filepreview/info/exclude b/modules/markup/tests/repo/repo1_filepreview/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20 b/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20
new file mode 100644
index 0000000..161d0ba
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/3f/ed9bce8610a52048747f627b3863374642c85c b/modules/markup/tests/repo/repo1_filepreview/objects/3f/ed9bce8610a52048747f627b3863374642c85c
new file mode 100644
index 0000000..ebcf076
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/3f/ed9bce8610a52048747f627b3863374642c85c
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
new file mode 100644
index 0000000..adf6411
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/4c/1aaf56bcb9f39dcf65f3f250726850aed13cd6 b/modules/markup/tests/repo/repo1_filepreview/objects/4c/1aaf56bcb9f39dcf65f3f250726850aed13cd6
new file mode 100644
index 0000000..b0857df
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/4c/1aaf56bcb9f39dcf65f3f250726850aed13cd6
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972 b/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972
new file mode 100644
index 0000000..1b87aa8
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969 b/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969
new file mode 100644
index 0000000..d38170a
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/8c/7e5a667f1b771847fe88c01c3de34413a1b220 b/modules/markup/tests/repo/repo1_filepreview/objects/8c/7e5a667f1b771847fe88c01c3de34413a1b220
new file mode 100644
index 0000000..c22450a
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/8c/7e5a667f1b771847fe88c01c3de34413a1b220
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c b/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c
new file mode 100644
index 0000000..fe37c11
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c
Binary files differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d b/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d
new file mode 100644
index 0000000..e13ca64
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d
@@ -0,0 +1 @@
+x+)JMU06e040031QHËÌIÕKÏghQºÂ/TX'·7潊ç·såË#3‹ô \ No newline at end of file
diff --git a/modules/markup/tests/repo/repo1_filepreview/refs/heads/master b/modules/markup/tests/repo/repo1_filepreview/refs/heads/master
new file mode 100644
index 0000000..df25bf4
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/refs/heads/master
@@ -0,0 +1 @@
+4c1aaf56bcb9f39dcf65f3f250726850aed13cd6
diff --git a/modules/mcaptcha/mcaptcha.go b/modules/mcaptcha/mcaptcha.go
new file mode 100644
index 0000000..74142aa
--- /dev/null
+++ b/modules/mcaptcha/mcaptcha.go
@@ -0,0 +1,26 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mcaptcha
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "codeberg.org/gusted/mcaptcha"
+)
+
+func Verify(ctx context.Context, token string) (bool, error) {
+ valid, err := mcaptcha.Verify(ctx, &mcaptcha.VerifyOpts{
+ InstanceURL: setting.Service.McaptchaURL,
+ Sitekey: setting.Service.McaptchaSitekey,
+ Secret: setting.Service.McaptchaSecret,
+ Token: token,
+ })
+ if err != nil {
+ return false, fmt.Errorf("wasn't able to verify mCaptcha: %w", err)
+ }
+ return valid, nil
+}
diff --git a/modules/metrics/collector.go b/modules/metrics/collector.go
new file mode 100755
index 0000000..230260f
--- /dev/null
+++ b/modules/metrics/collector.go
@@ -0,0 +1,388 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package metrics
+
+import (
+ "runtime"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+const namespace = "gitea_"
+
+// Collector implements the prometheus.Collector interface and
+// exposes gitea metrics for prometheus
+type Collector struct {
+ Accesses *prometheus.Desc
+ Attachments *prometheus.Desc
+ BuildInfo *prometheus.Desc
+ Comments *prometheus.Desc
+ Follows *prometheus.Desc
+ HookTasks *prometheus.Desc
+ Issues *prometheus.Desc
+ IssuesOpen *prometheus.Desc
+ IssuesClosed *prometheus.Desc
+ IssuesByLabel *prometheus.Desc
+ IssuesByRepository *prometheus.Desc
+ Labels *prometheus.Desc
+ LoginSources *prometheus.Desc
+ Milestones *prometheus.Desc
+ Mirrors *prometheus.Desc
+ Oauths *prometheus.Desc
+ Organizations *prometheus.Desc
+ Projects *prometheus.Desc
+ ProjectColumns *prometheus.Desc
+ PublicKeys *prometheus.Desc
+ Releases *prometheus.Desc
+ Repositories *prometheus.Desc
+ Stars *prometheus.Desc
+ Teams *prometheus.Desc
+ UpdateTasks *prometheus.Desc
+ Users *prometheus.Desc
+ Watches *prometheus.Desc
+ Webhooks *prometheus.Desc
+}
+
+// NewCollector returns a new Collector with all prometheus.Desc initialized
+func NewCollector() Collector {
+ return Collector{
+ Accesses: prometheus.NewDesc(
+ namespace+"accesses",
+ "Number of Accesses",
+ nil, nil,
+ ),
+ Attachments: prometheus.NewDesc(
+ namespace+"attachments",
+ "Number of Attachments",
+ nil, nil,
+ ),
+ BuildInfo: prometheus.NewDesc(
+ namespace+"build_info",
+ "Build information",
+ []string{
+ "goarch",
+ "goos",
+ "goversion",
+ "version",
+ }, nil,
+ ),
+ Comments: prometheus.NewDesc(
+ namespace+"comments",
+ "Number of Comments",
+ nil, nil,
+ ),
+ Follows: prometheus.NewDesc(
+ namespace+"follows",
+ "Number of Follows",
+ nil, nil,
+ ),
+ HookTasks: prometheus.NewDesc(
+ namespace+"hooktasks",
+ "Number of HookTasks",
+ nil, nil,
+ ),
+ Issues: prometheus.NewDesc(
+ namespace+"issues",
+ "Number of Issues",
+ nil, nil,
+ ),
+ IssuesByLabel: prometheus.NewDesc(
+ namespace+"issues_by_label",
+ "Number of Issues",
+ []string{"label"}, nil,
+ ),
+ IssuesByRepository: prometheus.NewDesc(
+ namespace+"issues_by_repository",
+ "Number of Issues",
+ []string{"repository"}, nil,
+ ),
+ IssuesOpen: prometheus.NewDesc(
+ namespace+"issues_open",
+ "Number of open Issues",
+ nil, nil,
+ ),
+ IssuesClosed: prometheus.NewDesc(
+ namespace+"issues_closed",
+ "Number of closed Issues",
+ nil, nil,
+ ),
+ Labels: prometheus.NewDesc(
+ namespace+"labels",
+ "Number of Labels",
+ nil, nil,
+ ),
+ LoginSources: prometheus.NewDesc(
+ namespace+"loginsources",
+ "Number of LoginSources",
+ nil, nil,
+ ),
+ Milestones: prometheus.NewDesc(
+ namespace+"milestones",
+ "Number of Milestones",
+ nil, nil,
+ ),
+ Mirrors: prometheus.NewDesc(
+ namespace+"mirrors",
+ "Number of Mirrors",
+ nil, nil,
+ ),
+ Oauths: prometheus.NewDesc(
+ namespace+"oauths",
+ "Number of Oauths",
+ nil, nil,
+ ),
+ Organizations: prometheus.NewDesc(
+ namespace+"organizations",
+ "Number of Organizations",
+ nil, nil,
+ ),
+ Projects: prometheus.NewDesc(
+ namespace+"projects",
+ "Number of projects",
+ nil, nil,
+ ),
+ ProjectColumns: prometheus.NewDesc(
+ namespace+"projects_boards", // TODO: change the key name will affect the consume's result history
+ "Number of project columns",
+ nil, nil,
+ ),
+ PublicKeys: prometheus.NewDesc(
+ namespace+"publickeys",
+ "Number of PublicKeys",
+ nil, nil,
+ ),
+ Releases: prometheus.NewDesc(
+ namespace+"releases",
+ "Number of Releases",
+ nil, nil,
+ ),
+ Repositories: prometheus.NewDesc(
+ namespace+"repositories",
+ "Number of Repositories",
+ nil, nil,
+ ),
+ Stars: prometheus.NewDesc(
+ namespace+"stars",
+ "Number of Stars",
+ nil, nil,
+ ),
+ Teams: prometheus.NewDesc(
+ namespace+"teams",
+ "Number of Teams",
+ nil, nil,
+ ),
+ UpdateTasks: prometheus.NewDesc(
+ namespace+"updatetasks",
+ "Number of UpdateTasks",
+ nil, nil,
+ ),
+ Users: prometheus.NewDesc(
+ namespace+"users",
+ "Number of Users",
+ nil, nil,
+ ),
+ Watches: prometheus.NewDesc(
+ namespace+"watches",
+ "Number of Watches",
+ nil, nil,
+ ),
+ Webhooks: prometheus.NewDesc(
+ namespace+"webhooks",
+ "Number of Webhooks",
+ nil, nil,
+ ),
+ }
+}
+
+// Describe returns all possible prometheus.Desc
+func (c Collector) Describe(ch chan<- *prometheus.Desc) {
+ ch <- c.Accesses
+ ch <- c.Attachments
+ ch <- c.BuildInfo
+ ch <- c.Comments
+ ch <- c.Follows
+ ch <- c.HookTasks
+ ch <- c.Issues
+ ch <- c.IssuesByLabel
+ ch <- c.IssuesByRepository
+ ch <- c.IssuesOpen
+ ch <- c.IssuesClosed
+ ch <- c.Labels
+ ch <- c.LoginSources
+ ch <- c.Milestones
+ ch <- c.Mirrors
+ ch <- c.Oauths
+ ch <- c.Organizations
+ ch <- c.Projects
+ ch <- c.ProjectColumns
+ ch <- c.PublicKeys
+ ch <- c.Releases
+ ch <- c.Repositories
+ ch <- c.Stars
+ ch <- c.Teams
+ ch <- c.UpdateTasks
+ ch <- c.Users
+ ch <- c.Watches
+ ch <- c.Webhooks
+}
+
+// Collect returns the metrics with values
+func (c Collector) Collect(ch chan<- prometheus.Metric) {
+ stats := activities_model.GetStatistic(db.DefaultContext)
+
+ ch <- prometheus.MustNewConstMetric(
+ c.Accesses,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Access),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Attachments,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Attachment),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.BuildInfo,
+ prometheus.GaugeValue,
+ 1,
+ runtime.GOARCH,
+ runtime.GOOS,
+ runtime.Version(),
+ setting.AppVer,
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Comments,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Comment),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Follows,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Follow),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.HookTasks,
+ prometheus.GaugeValue,
+ float64(stats.Counter.HookTask),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Issues,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Issue),
+ )
+ for _, il := range stats.Counter.IssueByLabel {
+ ch <- prometheus.MustNewConstMetric(
+ c.IssuesByLabel,
+ prometheus.GaugeValue,
+ float64(il.Count),
+ il.Label,
+ )
+ }
+ for _, ir := range stats.Counter.IssueByRepository {
+ ch <- prometheus.MustNewConstMetric(
+ c.IssuesByRepository,
+ prometheus.GaugeValue,
+ float64(ir.Count),
+ ir.OwnerName+"/"+ir.Repository,
+ )
+ }
+ ch <- prometheus.MustNewConstMetric(
+ c.IssuesClosed,
+ prometheus.GaugeValue,
+ float64(stats.Counter.IssueClosed),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.IssuesOpen,
+ prometheus.GaugeValue,
+ float64(stats.Counter.IssueOpen),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Labels,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Label),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.LoginSources,
+ prometheus.GaugeValue,
+ float64(stats.Counter.AuthSource),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Milestones,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Milestone),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Mirrors,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Mirror),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Oauths,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Oauth),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Organizations,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Org),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Projects,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Project),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.ProjectColumns,
+ prometheus.GaugeValue,
+ float64(stats.Counter.ProjectColumn),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.PublicKeys,
+ prometheus.GaugeValue,
+ float64(stats.Counter.PublicKey),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Releases,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Release),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Repositories,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Repo),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Stars,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Star),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Teams,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Team),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.UpdateTasks,
+ prometheus.GaugeValue,
+ float64(stats.Counter.UpdateTask),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Users,
+ prometheus.GaugeValue,
+ float64(stats.Counter.User),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Watches,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Watch),
+ )
+ ch <- prometheus.MustNewConstMetric(
+ c.Webhooks,
+ prometheus.GaugeValue,
+ float64(stats.Counter.Webhook),
+ )
+}
diff --git a/modules/migration/comment.go b/modules/migration/comment.go
new file mode 100644
index 0000000..e041758
--- /dev/null
+++ b/modules/migration/comment.go
@@ -0,0 +1,34 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "time"
+
+// Commentable can be commented upon
+type Commentable interface {
+ Reviewable
+ GetContext() DownloaderContext
+}
+
+// Comment is a standard comment information
+type Comment struct {
+ IssueIndex int64 `yaml:"issue_index"`
+ Index int64
+ CommentType string `yaml:"comment_type"` // see `commentStrings` in models/issues/comment.go
+ PosterID int64 `yaml:"poster_id"`
+ PosterName string `yaml:"poster_name"`
+ PosterEmail string `yaml:"poster_email"`
+ Created time.Time
+ Updated time.Time
+ Content string
+ Reactions []*Reaction
+ Meta map[string]any `yaml:"meta,omitempty"` // see models/issues/comment.go for fields in Comment struct
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (c *Comment) GetExternalName() string { return c.PosterName }
+
+// ExternalID ExternalUserMigrated interface
+func (c *Comment) GetExternalID() int64 { return c.PosterID }
diff --git a/modules/migration/downloader.go b/modules/migration/downloader.go
new file mode 100644
index 0000000..08dbbc2
--- /dev/null
+++ b/modules/migration/downloader.go
@@ -0,0 +1,37 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/structs"
+)
+
+// Downloader downloads the site repo information
+type Downloader interface {
+ SetContext(context.Context)
+ GetRepoInfo() (*Repository, error)
+ GetTopics() ([]string, error)
+ GetMilestones() ([]*Milestone, error)
+ GetReleases() ([]*Release, error)
+ GetLabels() ([]*Label, error)
+ GetIssues(page, perPage int) ([]*Issue, bool, error)
+ GetComments(commentable Commentable) ([]*Comment, bool, error)
+ GetAllComments(page, perPage int) ([]*Comment, bool, error)
+ SupportGetRepoComments() bool
+ GetPullRequests(page, perPage int) ([]*PullRequest, bool, error)
+ GetReviews(reviewable Reviewable) ([]*Review, error)
+ FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error)
+}
+
+// DownloaderFactory defines an interface to match a downloader implementation and create a downloader
+type DownloaderFactory interface {
+ New(ctx context.Context, opts MigrateOptions) (Downloader, error)
+ GitServiceType() structs.GitServiceType
+}
+
+// DownloaderContext has opaque information only relevant to a given downloader
+type DownloaderContext any
diff --git a/modules/migration/error.go b/modules/migration/error.go
new file mode 100644
index 0000000..64cda9d
--- /dev/null
+++ b/modules/migration/error.go
@@ -0,0 +1,25 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "fmt"
+
+// ErrNotSupported represents status if a downloader do not supported something.
+type ErrNotSupported struct {
+ Entity string
+}
+
+// IsErrNotSupported checks if an error is an ErrNotSupported
+func IsErrNotSupported(err error) bool {
+ _, ok := err.(ErrNotSupported)
+ return ok
+}
+
+// Error return error message
+func (err ErrNotSupported) Error() string {
+ if len(err.Entity) != 0 {
+ return fmt.Sprintf("'%s' not supported", err.Entity)
+ }
+ return "not supported"
+}
diff --git a/modules/migration/file_format.go b/modules/migration/file_format.go
new file mode 100644
index 0000000..d29d24d
--- /dev/null
+++ b/modules/migration/file_format.go
@@ -0,0 +1,110 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "gopkg.in/yaml.v3"
+)
+
+// Load project data from file, with optional validation
+func Load(filename string, data any, validation bool) error {
+ isJSON := strings.HasSuffix(filename, ".json")
+
+ bs, err := os.ReadFile(filename)
+ if err != nil {
+ return err
+ }
+
+ if validation {
+ err := validate(bs, data, isJSON)
+ if err != nil {
+ return err
+ }
+ }
+ return unmarshal(bs, data, isJSON)
+}
+
+func unmarshal(bs []byte, data any, isJSON bool) error {
+ if isJSON {
+ return json.Unmarshal(bs, data)
+ }
+ return yaml.Unmarshal(bs, data)
+}
+
+func getSchema(filename string) (*jsonschema.Schema, error) {
+ c := jsonschema.NewCompiler()
+ c.UseLoader(&SchemaLoader{})
+ return c.Compile(filename)
+}
+
+func validate(bs []byte, datatype any, isJSON bool) error {
+ var v any
+ err := unmarshal(bs, &v, isJSON)
+ if err != nil {
+ return err
+ }
+ if !isJSON {
+ v, err = toStringKeys(v)
+ if err != nil {
+ return err
+ }
+ }
+
+ var schemaFilename string
+ switch datatype := datatype.(type) {
+ case *[]*Issue:
+ schemaFilename = "issue.json"
+ case *[]*Milestone:
+ schemaFilename = "milestone.json"
+ default:
+ return fmt.Errorf("file_format:validate: %T has not a validation implemented", datatype)
+ }
+
+ sch, err := getSchema(schemaFilename)
+ if err != nil {
+ return err
+ }
+ err = sch.Validate(v)
+ if err != nil {
+ log.Error("migration validation with %s failed:\n%#v", schemaFilename, err)
+ }
+ return err
+}
+
+func toStringKeys(val any) (any, error) {
+ var err error
+ switch val := val.(type) {
+ case map[string]any:
+ m := make(map[string]any)
+ for k, v := range val {
+ m[k], err = toStringKeys(v)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return m, nil
+ case []any:
+ l := make([]any, len(val))
+ for i, v := range val {
+ l[i], err = toStringKeys(v)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return l, nil
+ case time.Time:
+ return val.Format(time.RFC3339), nil
+ default:
+ return val, nil
+ }
+}
diff --git a/modules/migration/file_format_test.go b/modules/migration/file_format_test.go
new file mode 100644
index 0000000..f6651cd
--- /dev/null
+++ b/modules/migration/file_format_test.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrationJSON_IssueOK(t *testing.T) {
+ issues := make([]*Issue, 0, 10)
+ err := Load("file_format_testdata/issue_a.json", &issues, true)
+ require.NoError(t, err)
+ err = Load("file_format_testdata/issue_a.yml", &issues, true)
+ require.NoError(t, err)
+}
+
+func TestMigrationJSON_IssueFail(t *testing.T) {
+ issues := make([]*Issue, 0, 10)
+ err := Load("file_format_testdata/issue_b.json", &issues, true)
+ if _, ok := err.(*jsonschema.ValidationError); ok {
+ errors := strings.Split(err.(*jsonschema.ValidationError).GoString(), "\n")
+ assert.Contains(t, errors[1], "missing properties")
+ assert.Contains(t, errors[1], "poster_id")
+ } else {
+ t.Fatalf("got: type %T with value %s, want: *jsonschema.ValidationError", err, err)
+ }
+}
+
+func TestMigrationJSON_MilestoneOK(t *testing.T) {
+ milestones := make([]*Milestone, 0, 10)
+ err := Load("file_format_testdata/milestones.json", &milestones, true)
+ require.NoError(t, err)
+}
diff --git a/modules/migration/file_format_testdata/issue_a.json b/modules/migration/file_format_testdata/issue_a.json
new file mode 100644
index 0000000..33d7759
--- /dev/null
+++ b/modules/migration/file_format_testdata/issue_a.json
@@ -0,0 +1,14 @@
+[
+ {
+ "number": 1,
+ "poster_id": 1,
+ "poster_name": "name_a",
+ "title": "title_a",
+ "content": "content_a",
+ "state": "closed",
+ "is_locked": false,
+ "created": "1985-04-12T23:20:50.52Z",
+ "updated": "1986-04-12T23:20:50.52Z",
+ "closed": "1987-04-12T23:20:50.52Z"
+ }
+]
diff --git a/modules/migration/file_format_testdata/issue_a.yml b/modules/migration/file_format_testdata/issue_a.yml
new file mode 100644
index 0000000..d03bfb3
--- /dev/null
+++ b/modules/migration/file_format_testdata/issue_a.yml
@@ -0,0 +1,10 @@
+- number: 1
+ poster_id: 1
+ poster_name: name_a
+ title: title_a
+ content: content_a
+ state: closed
+ is_locked: false
+ created: 2021-05-27T15:24:13+02:00
+ updated: 2021-11-11T10:52:45+01:00
+ closed: 2021-11-11T10:52:45+01:00
diff --git a/modules/migration/file_format_testdata/issue_b.json b/modules/migration/file_format_testdata/issue_b.json
new file mode 100644
index 0000000..2a824d4
--- /dev/null
+++ b/modules/migration/file_format_testdata/issue_b.json
@@ -0,0 +1,5 @@
+[
+ {
+ "number": 1
+ }
+]
diff --git a/modules/migration/file_format_testdata/milestones.json b/modules/migration/file_format_testdata/milestones.json
new file mode 100644
index 0000000..8fb770d
--- /dev/null
+++ b/modules/migration/file_format_testdata/milestones.json
@@ -0,0 +1,20 @@
+[
+ {
+ "title": "title_a",
+ "description": "description_a",
+ "deadline": "1988-04-12T23:20:50.52Z",
+ "created": "1985-04-12T23:20:50.52Z",
+ "updated": "1986-04-12T23:20:50.52Z",
+ "closed": "1987-04-12T23:20:50.52Z",
+ "state": "closed"
+ },
+ {
+ "title": "title_b",
+ "description": "description_b",
+ "deadline": "1998-04-12T23:20:50.52Z",
+ "created": "1995-04-12T23:20:50.52Z",
+ "updated": "1996-04-12T23:20:50.52Z",
+ "closed": null,
+ "state": "open"
+ }
+]
diff --git a/modules/migration/issue.go b/modules/migration/issue.go
new file mode 100644
index 0000000..3d1d1b4
--- /dev/null
+++ b/modules/migration/issue.go
@@ -0,0 +1,48 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "time"
+
+// Issue is a standard issue information
+type Issue struct {
+ Number int64 `json:"number"`
+ PosterID int64 `yaml:"poster_id" json:"poster_id"`
+ PosterName string `yaml:"poster_name" json:"poster_name"`
+ PosterEmail string `yaml:"poster_email" json:"poster_email"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ Ref string `json:"ref"`
+ Milestone string `json:"milestone"`
+ State string `json:"state"` // closed, open
+ IsLocked bool `yaml:"is_locked" json:"is_locked"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ Closed *time.Time `json:"closed"`
+ Labels []*Label `json:"labels"`
+ Reactions []*Reaction `json:"reactions"`
+ Assignees []string `json:"assignees"`
+ ForeignIndex int64 `json:"foreign_id"`
+ Context DownloaderContext `yaml:"-"`
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (issue *Issue) GetExternalName() string { return issue.PosterName }
+
+// GetExternalID ExternalUserMigrated interface
+func (issue *Issue) GetExternalID() int64 { return issue.PosterID }
+
+func (issue *Issue) GetLocalIndex() int64 { return issue.Number }
+
+func (issue *Issue) GetForeignIndex() int64 {
+ // see the comment of Reviewable.GetForeignIndex
+ // if there is no ForeignIndex, then use LocalIndex
+ if issue.ForeignIndex == 0 {
+ return issue.Number
+ }
+ return issue.ForeignIndex
+}
+
+func (issue *Issue) GetContext() DownloaderContext { return issue.Context }
diff --git a/modules/migration/label.go b/modules/migration/label.go
new file mode 100644
index 0000000..4927be3
--- /dev/null
+++ b/modules/migration/label.go
@@ -0,0 +1,13 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+// Label defines a standard label information
+type Label struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Description string `json:"description"`
+ Exclusive bool `json:"exclusive"`
+}
diff --git a/modules/migration/messenger.go b/modules/migration/messenger.go
new file mode 100644
index 0000000..6f9cad3
--- /dev/null
+++ b/modules/migration/messenger.go
@@ -0,0 +1,10 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+// Messenger is a formatting function similar to i18n.TrString
+type Messenger func(key string, args ...any)
+
+// NilMessenger represents an empty formatting function
+func NilMessenger(string, ...any) {}
diff --git a/modules/migration/milestone.go b/modules/migration/milestone.go
new file mode 100644
index 0000000..34355b8
--- /dev/null
+++ b/modules/migration/milestone.go
@@ -0,0 +1,18 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "time"
+
+// Milestone defines a standard milestone
+type Milestone struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Deadline *time.Time `json:"deadline"`
+ Created time.Time `json:"created"`
+ Updated *time.Time `json:"updated"`
+ Closed *time.Time `json:"closed"`
+ State string `json:"state"` // open, closed
+}
diff --git a/modules/migration/null_downloader.go b/modules/migration/null_downloader.go
new file mode 100644
index 0000000..e5b6933
--- /dev/null
+++ b/modules/migration/null_downloader.go
@@ -0,0 +1,88 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "context"
+ "net/url"
+)
+
+// NullDownloader implements a blank downloader
+type NullDownloader struct{}
+
+var _ Downloader = &NullDownloader{}
+
+// SetContext set context
+func (n NullDownloader) SetContext(_ context.Context) {}
+
+// GetRepoInfo returns a repository information
+func (n NullDownloader) GetRepoInfo() (*Repository, error) {
+ return nil, ErrNotSupported{Entity: "RepoInfo"}
+}
+
+// GetTopics return repository topics
+func (n NullDownloader) GetTopics() ([]string, error) {
+ return nil, ErrNotSupported{Entity: "Topics"}
+}
+
+// GetMilestones returns milestones
+func (n NullDownloader) GetMilestones() ([]*Milestone, error) {
+ return nil, ErrNotSupported{Entity: "Milestones"}
+}
+
+// GetReleases returns releases
+func (n NullDownloader) GetReleases() ([]*Release, error) {
+ return nil, ErrNotSupported{Entity: "Releases"}
+}
+
+// GetLabels returns labels
+func (n NullDownloader) GetLabels() ([]*Label, error) {
+ return nil, ErrNotSupported{Entity: "Labels"}
+}
+
+// GetIssues returns issues according start and limit
+func (n NullDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
+ return nil, false, ErrNotSupported{Entity: "Issues"}
+}
+
+// GetComments returns comments of an issue or PR
+func (n NullDownloader) GetComments(commentable Commentable) ([]*Comment, bool, error) {
+ return nil, false, ErrNotSupported{Entity: "Comments"}
+}
+
+// GetAllComments returns paginated comments
+func (n NullDownloader) GetAllComments(page, perPage int) ([]*Comment, bool, error) {
+ return nil, false, ErrNotSupported{Entity: "AllComments"}
+}
+
+// GetPullRequests returns pull requests according page and perPage
+func (n NullDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool, error) {
+ return nil, false, ErrNotSupported{Entity: "PullRequests"}
+}
+
+// GetReviews returns pull requests review
+func (n NullDownloader) GetReviews(reviewable Reviewable) ([]*Review, error) {
+ return nil, ErrNotSupported{Entity: "Reviews"}
+}
+
+// FormatCloneURL add authentication into remote URLs
+func (n NullDownloader) FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error) {
+ if len(opts.AuthToken) > 0 || len(opts.AuthUsername) > 0 {
+ u, err := url.Parse(remoteAddr)
+ if err != nil {
+ return "", err
+ }
+ u.User = url.UserPassword(opts.AuthUsername, opts.AuthPassword)
+ if len(opts.AuthToken) > 0 {
+ u.User = url.UserPassword("oauth2", opts.AuthToken)
+ }
+ return u.String(), nil
+ }
+ return remoteAddr, nil
+}
+
+// SupportGetRepoComments return true if it supports get repo comments
+func (n NullDownloader) SupportGetRepoComments() bool {
+ return false
+}
diff --git a/modules/migration/options.go b/modules/migration/options.go
new file mode 100644
index 0000000..234e72c
--- /dev/null
+++ b/modules/migration/options.go
@@ -0,0 +1,41 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "code.gitea.io/gitea/modules/structs"
+
+// MigrateOptions defines the way a repository gets migrated
+// this is for internal usage by migrations module and func who interact with it
+type MigrateOptions struct {
+ // required: true
+ CloneAddr string `json:"clone_addr" binding:"Required"`
+ CloneAddrEncrypted string `json:"clone_addr_encrypted,omitempty"`
+ AuthUsername string `json:"auth_username"`
+ AuthPassword string `json:"-"`
+ AuthPasswordEncrypted string `json:"auth_password_encrypted,omitempty"`
+ AuthToken string `json:"-"`
+ AuthTokenEncrypted string `json:"auth_token_encrypted,omitempty"`
+ // required: true
+ UID int `json:"uid" binding:"Required"`
+ // required: true
+ RepoName string `json:"repo_name" binding:"Required"`
+ Mirror bool `json:"mirror"`
+ LFS bool `json:"lfs"`
+ LFSEndpoint string `json:"lfs_endpoint"`
+ Private bool `json:"private"`
+ Description string `json:"description"`
+ OriginalURL string
+ GitServiceType structs.GitServiceType
+ Wiki bool
+ Issues bool
+ Milestones bool
+ Labels bool
+ Releases bool
+ Comments bool
+ PullRequests bool
+ ReleaseAssets bool
+ MigrateToRepoID int64
+ MirrorInterval string `json:"mirror_interval"`
+}
diff --git a/modules/migration/pullrequest.go b/modules/migration/pullrequest.go
new file mode 100644
index 0000000..1435991
--- /dev/null
+++ b/modules/migration/pullrequest.go
@@ -0,0 +1,74 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// PullRequest defines a standard pull request information
+type PullRequest struct {
+ Number int64
+ Title string
+ PosterName string `yaml:"poster_name"`
+ PosterID int64 `yaml:"poster_id"`
+ PosterEmail string `yaml:"poster_email"`
+ Content string
+ Milestone string
+ State string
+ Created time.Time
+ Updated time.Time
+ Closed *time.Time
+ Labels []*Label
+ PatchURL string `yaml:"patch_url"` // SECURITY: This must be safe to download directly from
+ Merged bool
+ MergedTime *time.Time `yaml:"merged_time"`
+ MergeCommitSHA string `yaml:"merge_commit_sha"`
+ Head PullRequestBranch
+ Base PullRequestBranch
+ Assignees []string
+ IsLocked bool `yaml:"is_locked"`
+ Reactions []*Reaction
+ ForeignIndex int64
+ Context DownloaderContext `yaml:"-"`
+ EnsuredSafe bool `yaml:"ensured_safe"`
+}
+
+func (p *PullRequest) GetLocalIndex() int64 { return p.Number }
+func (p *PullRequest) GetForeignIndex() int64 { return p.ForeignIndex }
+func (p *PullRequest) GetContext() DownloaderContext { return p.Context }
+
+// IsForkPullRequest returns true if the pull request from a forked repository but not the same repository
+func (p *PullRequest) IsForkPullRequest() bool {
+ return p.Head.RepoFullName() != p.Base.RepoFullName()
+}
+
+// GetGitRefName returns pull request relative path to head
+func (p PullRequest) GetGitRefName() string {
+ return fmt.Sprintf("%s%d/head", git.PullPrefix, p.Number)
+}
+
+// PullRequestBranch represents a pull request branch
+type PullRequestBranch struct {
+ CloneURL string `yaml:"clone_url"` // SECURITY: This must be safe to download from
+ Ref string // SECURITY: this must be a git.IsValidRefPattern
+ SHA string // SECURITY: this must be a git.IsValidSHAPattern
+ RepoName string `yaml:"repo_name"`
+ OwnerName string `yaml:"owner_name"`
+}
+
+// RepoFullName returns pull request repo full name
+func (p PullRequestBranch) RepoFullName() string {
+ return fmt.Sprintf("%s/%s", p.OwnerName, p.RepoName)
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (p *PullRequest) GetExternalName() string { return p.PosterName }
+
+// ExternalID ExternalUserMigrated interface
+func (p *PullRequest) GetExternalID() int64 { return p.PosterID }
diff --git a/modules/migration/reaction.go b/modules/migration/reaction.go
new file mode 100644
index 0000000..ca1df6c
--- /dev/null
+++ b/modules/migration/reaction.go
@@ -0,0 +1,17 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+// Reaction represents a reaction to an issue/pr/comment.
+type Reaction struct {
+ UserID int64 `yaml:"user_id" json:"user_id"`
+ UserName string `yaml:"user_name" json:"user_name"`
+ Content string `json:"content"`
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (r *Reaction) GetExternalName() string { return r.UserName }
+
+// GetExternalID ExternalUserMigrated interface
+func (r *Reaction) GetExternalID() int64 { return r.UserID }
diff --git a/modules/migration/release.go b/modules/migration/release.go
new file mode 100644
index 0000000..f92cf25
--- /dev/null
+++ b/modules/migration/release.go
@@ -0,0 +1,46 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "io"
+ "time"
+)
+
+// ReleaseAsset represents a release asset
+type ReleaseAsset struct {
+ ID int64
+ Name string
+ ContentType *string `yaml:"content_type"`
+ Size *int
+ DownloadCount *int `yaml:"download_count"`
+ Created time.Time
+ Updated time.Time
+
+ DownloadURL *string `yaml:"download_url"` // SECURITY: It is the responsibility of downloader to make sure this is safe
+ // if DownloadURL is nil, the function should be invoked
+ DownloadFunc func() (io.ReadCloser, error) `yaml:"-"` // SECURITY: It is the responsibility of downloader to make sure this is safe
+}
+
+// Release represents a release
+type Release struct {
+ TagName string `yaml:"tag_name"` // SECURITY: This must pass git.IsValidRefPattern
+ TargetCommitish string `yaml:"target_commitish"` // SECURITY: This must pass git.IsValidRefPattern
+ Name string
+ Body string
+ Draft bool
+ Prerelease bool
+ PublisherID int64 `yaml:"publisher_id"`
+ PublisherName string `yaml:"publisher_name"`
+ PublisherEmail string `yaml:"publisher_email"`
+ Assets []*ReleaseAsset
+ Created time.Time
+ Published time.Time
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (r *Release) GetExternalName() string { return r.PublisherName }
+
+// GetExternalID ExternalUserMigrated interface
+func (r *Release) GetExternalID() int64 { return r.PublisherID }
diff --git a/modules/migration/repo.go b/modules/migration/repo.go
new file mode 100644
index 0000000..22c2cf6
--- /dev/null
+++ b/modules/migration/repo.go
@@ -0,0 +1,17 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+// Repository defines a standard repository information
+type Repository struct {
+ Name string
+ Owner string
+ IsPrivate bool `yaml:"is_private"`
+ IsMirror bool `yaml:"is_mirror"`
+ Description string
+ CloneURL string `yaml:"clone_url"` // SECURITY: This must be checked to ensure that is safe to be used
+ OriginalURL string `yaml:"original_url"`
+ DefaultBranch string
+}
diff --git a/modules/migration/retry_downloader.go b/modules/migration/retry_downloader.go
new file mode 100644
index 0000000..1cacf5f
--- /dev/null
+++ b/modules/migration/retry_downloader.go
@@ -0,0 +1,194 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import (
+ "context"
+ "time"
+)
+
+var _ Downloader = &RetryDownloader{}
+
+// RetryDownloader retry the downloads
+type RetryDownloader struct {
+ Downloader
+ ctx context.Context
+ RetryTimes int // the total execute times
+ RetryDelay int // time to delay seconds
+}
+
+// NewRetryDownloader creates a retry downloader
+func NewRetryDownloader(ctx context.Context, downloader Downloader, retryTimes, retryDelay int) *RetryDownloader {
+ return &RetryDownloader{
+ Downloader: downloader,
+ ctx: ctx,
+ RetryTimes: retryTimes,
+ RetryDelay: retryDelay,
+ }
+}
+
+func (d *RetryDownloader) retry(work func() error) error {
+ var (
+ times = d.RetryTimes
+ err error
+ )
+ for ; times > 0; times-- {
+ if err = work(); err == nil {
+ return nil
+ }
+ if IsErrNotSupported(err) {
+ return err
+ }
+ select {
+ case <-d.ctx.Done():
+ return d.ctx.Err()
+ case <-time.After(time.Second * time.Duration(d.RetryDelay)):
+ }
+ }
+ return err
+}
+
+// SetContext set context
+func (d *RetryDownloader) SetContext(ctx context.Context) {
+ d.ctx = ctx
+ d.Downloader.SetContext(ctx)
+}
+
+// GetRepoInfo returns a repository information with retry
+func (d *RetryDownloader) GetRepoInfo() (*Repository, error) {
+ var (
+ repo *Repository
+ err error
+ )
+
+ err = d.retry(func() error {
+ repo, err = d.Downloader.GetRepoInfo()
+ return err
+ })
+
+ return repo, err
+}
+
+// GetTopics returns a repository's topics with retry
+func (d *RetryDownloader) GetTopics() ([]string, error) {
+ var (
+ topics []string
+ err error
+ )
+
+ err = d.retry(func() error {
+ topics, err = d.Downloader.GetTopics()
+ return err
+ })
+
+ return topics, err
+}
+
+// GetMilestones returns a repository's milestones with retry
+func (d *RetryDownloader) GetMilestones() ([]*Milestone, error) {
+ var (
+ milestones []*Milestone
+ err error
+ )
+
+ err = d.retry(func() error {
+ milestones, err = d.Downloader.GetMilestones()
+ return err
+ })
+
+ return milestones, err
+}
+
+// GetReleases returns a repository's releases with retry
+func (d *RetryDownloader) GetReleases() ([]*Release, error) {
+ var (
+ releases []*Release
+ err error
+ )
+
+ err = d.retry(func() error {
+ releases, err = d.Downloader.GetReleases()
+ return err
+ })
+
+ return releases, err
+}
+
+// GetLabels returns a repository's labels with retry
+func (d *RetryDownloader) GetLabels() ([]*Label, error) {
+ var (
+ labels []*Label
+ err error
+ )
+
+ err = d.retry(func() error {
+ labels, err = d.Downloader.GetLabels()
+ return err
+ })
+
+ return labels, err
+}
+
+// GetIssues returns a repository's issues with retry
+func (d *RetryDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
+ var (
+ issues []*Issue
+ isEnd bool
+ err error
+ )
+
+ err = d.retry(func() error {
+ issues, isEnd, err = d.Downloader.GetIssues(page, perPage)
+ return err
+ })
+
+ return issues, isEnd, err
+}
+
+// GetComments returns a repository's comments with retry
+func (d *RetryDownloader) GetComments(commentable Commentable) ([]*Comment, bool, error) {
+ var (
+ comments []*Comment
+ isEnd bool
+ err error
+ )
+
+ err = d.retry(func() error {
+ comments, isEnd, err = d.Downloader.GetComments(commentable)
+ return err
+ })
+
+ return comments, isEnd, err
+}
+
+// GetPullRequests returns a repository's pull requests with retry
+func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool, error) {
+ var (
+ prs []*PullRequest
+ err error
+ isEnd bool
+ )
+
+ err = d.retry(func() error {
+ prs, isEnd, err = d.Downloader.GetPullRequests(page, perPage)
+ return err
+ })
+
+ return prs, isEnd, err
+}
+
+// GetReviews returns pull requests reviews
+func (d *RetryDownloader) GetReviews(reviewable Reviewable) ([]*Review, error) {
+ var (
+ reviews []*Review
+ err error
+ )
+
+ err = d.retry(func() error {
+ reviews, err = d.Downloader.GetReviews(reviewable)
+ return err
+ })
+
+ return reviews, err
+}
diff --git a/modules/migration/review.go b/modules/migration/review.go
new file mode 100644
index 0000000..79e821b
--- /dev/null
+++ b/modules/migration/review.go
@@ -0,0 +1,67 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+import "time"
+
+// Reviewable can be reviewed
+type Reviewable interface {
+ GetLocalIndex() int64
+
+ // GetForeignIndex presents the foreign index, which could be misused:
+ // For example, if there are 2 Gitea sites: site-A exports a dataset, then site-B imports it:
+ // * if site-A exports files by using its LocalIndex
+ // * from site-A's view, LocalIndex is site-A's IssueIndex while ForeignIndex is site-B's IssueIndex
+ // * but from site-B's view, LocalIndex is site-B's IssueIndex while ForeignIndex is site-A's IssueIndex
+ //
+ // So the exporting/importing must be paired, but the meaning of them looks confusing then:
+ // * either site-A and site-B both use LocalIndex during dumping/restoring
+ // * or site-A and site-B both use ForeignIndex
+ GetForeignIndex() int64
+}
+
+// enumerate all review states
+const (
+ ReviewStatePending = "PENDING"
+ ReviewStateApproved = "APPROVED"
+ ReviewStateChangesRequested = "CHANGES_REQUESTED"
+ ReviewStateCommented = "COMMENTED"
+ ReviewStateRequestReview = "REQUEST_REVIEW"
+)
+
+// Review is a standard review information
+type Review struct {
+ ID int64
+ IssueIndex int64 `yaml:"issue_index"`
+ ReviewerID int64 `yaml:"reviewer_id"`
+ ReviewerName string `yaml:"reviewer_name"`
+ Official bool
+ CommitID string `yaml:"commit_id"`
+ Content string
+ CreatedAt time.Time `yaml:"created_at"`
+ State string // PENDING, APPROVED, REQUEST_CHANGES, or COMMENT
+ Comments []*ReviewComment
+}
+
+// GetExternalName ExternalUserMigrated interface
+func (r *Review) GetExternalName() string { return r.ReviewerName }
+
+// GetExternalID ExternalUserMigrated interface
+func (r *Review) GetExternalID() int64 { return r.ReviewerID }
+
+// ReviewComment represents a review comment
+type ReviewComment struct {
+ ID int64
+ InReplyTo int64 `yaml:"in_reply_to"`
+ Content string
+ TreePath string `yaml:"tree_path"`
+ DiffHunk string `yaml:"diff_hunk"`
+ Position int
+ Line int
+ CommitID string `yaml:"commit_id"`
+ PosterID int64 `yaml:"poster_id"`
+ Reactions []*Reaction
+ CreatedAt time.Time `yaml:"created_at"`
+ UpdatedAt time.Time `yaml:"updated_at"`
+}
diff --git a/modules/migration/schemas/issue.json b/modules/migration/schemas/issue.json
new file mode 100644
index 0000000..25753c3
--- /dev/null
+++ b/modules/migration/schemas/issue.json
@@ -0,0 +1,114 @@
+{
+ "title": "Issue",
+ "description": "Issues associated to a repository within a forge (Gitea, GitLab, etc.).",
+
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "number": {
+ "description": "Unique identifier, relative to the repository.",
+ "type": "number"
+ },
+ "poster_id": {
+ "description": "Unique identifier of the user who authored the issue.",
+ "type": "number"
+ },
+ "poster_name": {
+ "description": "Name of the user who authored the issue.",
+ "type": "string"
+ },
+ "poster_email": {
+ "description": "Email of the user who authored the issue.",
+ "type": "string"
+ },
+ "title": {
+ "description": "Short description displayed as the title.",
+ "type": "string"
+ },
+ "content": {
+ "description": "Long, multiline, description.",
+ "type": "string"
+ },
+ "ref": {
+ "description": "Target branch in the repository.",
+ "type": "string"
+ },
+ "milestone": {
+ "description": "Name of the milestone.",
+ "type": "string"
+ },
+ "state": {
+ "description": "A 'closed' issue will not see any activity in the future, otherwise it is 'open'.",
+ "enum": [
+ "closed",
+ "open"
+ ]
+ },
+ "is_locked": {
+ "description": "A locked issue can only be modified by privileged users.",
+ "type": "boolean"
+ },
+ "created": {
+ "description": "Creation time.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated": {
+ "description": "Last update time.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "closed": {
+ "description": "The last time 'state' changed to 'closed'.",
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "labels": {
+ "description": "List of labels.",
+ "type": "array",
+ "items": {
+ "$ref": "label.json"
+ }
+ },
+ "reactions": {
+ "description": "List of reactions.",
+ "type": "array",
+ "items": {
+ "$ref": "reaction.json"
+ }
+ },
+ "assignees": {
+ "description": "List of assignees.",
+ "type": "array",
+ "items": {
+ "description": "Name of a user assigned to the issue.",
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "number",
+ "poster_id",
+ "poster_name",
+ "title",
+ "content",
+ "state",
+ "is_locked",
+ "created",
+ "updated"
+ ]
+ },
+
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "$id": "http://example.com/issue.json",
+ "$$target": "issue.json"
+}
diff --git a/modules/migration/schemas/label.json b/modules/migration/schemas/label.json
new file mode 100644
index 0000000..561a2e3
--- /dev/null
+++ b/modules/migration/schemas/label.json
@@ -0,0 +1,28 @@
+{
+ "title": "Label",
+ "description": "Label associated to an issue.",
+
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "description": "Name of the label, unique within the repository.",
+ "type": "string"
+ },
+ "color": {
+ "description": "Color code of the label.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Long, multiline, description.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "$id": "label.json",
+ "$$target": "label.json"
+}
diff --git a/modules/migration/schemas/milestone.json b/modules/migration/schemas/milestone.json
new file mode 100644
index 0000000..7024ef4
--- /dev/null
+++ b/modules/migration/schemas/milestone.json
@@ -0,0 +1,67 @@
+{
+ "title": "Milestone",
+ "description": "Milestone associated to a repository within a forge.",
+
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "title": {
+ "description": "Short description.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Long, multiline, description.",
+ "type": "string"
+ },
+ "deadline": {
+ "description": "Deadline after which the milestone is overdue.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "created": {
+ "description": "Creation time.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated": {
+ "description": "Last update time.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "closed": {
+ "description": "The last time 'state' changed to 'closed'.",
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "state": {
+ "description": "A 'closed' issue will not see any activity in the future, otherwise it is 'open'.",
+ "enum": [
+ "closed",
+ "open"
+ ]
+ }
+ },
+ "required": [
+ "title",
+ "description",
+ "deadline",
+ "created",
+ "updated",
+ "closed",
+ "state"
+ ]
+ },
+
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "$id": "http://example.com/milestone.json",
+ "$$target": "milestone.json"
+}
diff --git a/modules/migration/schemas/reaction.json b/modules/migration/schemas/reaction.json
new file mode 100644
index 0000000..2565251
--- /dev/null
+++ b/modules/migration/schemas/reaction.json
@@ -0,0 +1,29 @@
+{
+ "title": "Reaction",
+ "description": "Reaction associated to an issue or a comment.",
+
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "user_id": {
+ "description": "Unique identifier of the user who authored the reaction.",
+ "type": "number"
+ },
+ "user_name": {
+ "description": "Name of the user who authored the reaction.",
+ "type": "string"
+ },
+ "content": {
+ "description": "Representation of the reaction",
+ "type": "string"
+ }
+ },
+ "required": [
+ "user_id",
+ "content"
+ ],
+
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "$id": "http://example.com/reaction.json",
+ "$$target": "reaction.json"
+}
diff --git a/modules/migration/schemas_bindata.go b/modules/migration/schemas_bindata.go
new file mode 100644
index 0000000..c5db3b3
--- /dev/null
+++ b/modules/migration/schemas_bindata.go
@@ -0,0 +1,8 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package migration
+
+//go:generate go run ../../build/generate-bindata.go ../../modules/migration/schemas migration bindata.go
diff --git a/modules/migration/schemas_dynamic.go b/modules/migration/schemas_dynamic.go
new file mode 100644
index 0000000..3741691
--- /dev/null
+++ b/modules/migration/schemas_dynamic.go
@@ -0,0 +1,47 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package migration
+
+import (
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+)
+
+type SchemaLoader struct{}
+
+func (*SchemaLoader) Load(s string) (any, error) {
+ u, err := url.Parse(s)
+ if err != nil {
+ return nil, err
+ }
+ basename := path.Base(u.Path)
+ filename := basename
+ //
+ // Schema reference each other within the schemas directory but
+ // the tests run in the parent directory.
+ //
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ filename = filepath.Join("schemas", basename)
+ //
+ // Integration tests run from the git root directory, not the
+ // directory in which the test source is located.
+ //
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ filename = filepath.Join("modules/migration/schemas", basename)
+ }
+ }
+
+ f, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return jsonschema.UnmarshalJSON(f)
+}
diff --git a/modules/migration/schemas_static.go b/modules/migration/schemas_static.go
new file mode 100644
index 0000000..832dfd8
--- /dev/null
+++ b/modules/migration/schemas_static.go
@@ -0,0 +1,23 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package migration
+
+import (
+ "path"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+)
+
+type SchemaLoader struct{}
+
+func (*SchemaLoader) Load(filename string) (any, error) {
+ f, err := Assets.Open(path.Base(filename))
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return jsonschema.UnmarshalJSON(f)
+}
diff --git a/modules/migration/uploader.go b/modules/migration/uploader.go
new file mode 100644
index 0000000..ff642aa
--- /dev/null
+++ b/modules/migration/uploader.go
@@ -0,0 +1,23 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2018 Jonas Franz. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migration
+
+// Uploader uploads all the information of one repository
+type Uploader interface {
+ MaxBatchInsertSize(tp string) int
+ CreateRepo(repo *Repository, opts MigrateOptions) error
+ CreateTopics(topic ...string) error
+ CreateMilestones(milestones ...*Milestone) error
+ CreateReleases(releases ...*Release) error
+ SyncTags() error
+ CreateLabels(labels ...*Label) error
+ CreateIssues(issues ...*Issue) error
+ CreateComments(comments ...*Comment) error
+ CreatePullRequests(prs ...*PullRequest) error
+ CreateReviews(reviews ...*Review) error
+ Rollback() error
+ Finish() error
+ Close()
+}
diff --git a/modules/nosql/leveldb.go b/modules/nosql/leveldb.go
new file mode 100644
index 0000000..aac5b21
--- /dev/null
+++ b/modules/nosql/leveldb.go
@@ -0,0 +1,24 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import "net/url"
+
+// ToLevelDBURI converts old style connections to a LevelDBURI
+//
+// A LevelDBURI matches the pattern:
+//
+// leveldb://path[?[option=value]*]
+//
+// We have previously just provided the path but this prevent other options
+func ToLevelDBURI(connection string) *url.URL {
+ uri, err := url.Parse(connection)
+ if err == nil && uri.Scheme == "leveldb" {
+ return uri
+ }
+ uri, _ = url.Parse("leveldb://common")
+ uri.Host = ""
+ uri.Path = connection
+ return uri
+}
diff --git a/modules/nosql/manager.go b/modules/nosql/manager.go
new file mode 100644
index 0000000..0ba2158
--- /dev/null
+++ b/modules/nosql/manager.go
@@ -0,0 +1,116 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "context"
+ "strconv"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/process"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+var manager *Manager
+
+// Manager is the nosql connection manager
+type Manager struct {
+ ctx context.Context
+ finished context.CancelFunc
+ mutex sync.Mutex
+
+ RedisConnections map[string]*redisClientHolder
+ LevelDBConnections map[string]*levelDBHolder
+}
+
+// RedisClient is a subset of redis.UniversalClient, it exposes less methods
+// to avoid generating machine code for unused methods. New method definitions
+// should be copied from the definitions in the Redis library github.com/redis/go-redis.
+type RedisClient interface {
+ // redis.GenericCmdable
+ Del(ctx context.Context, keys ...string) *redis.IntCmd
+ Exists(ctx context.Context, keys ...string) *redis.IntCmd
+
+ // redis.ListCmdable
+ RPush(ctx context.Context, key string, values ...any) *redis.IntCmd
+ LPop(ctx context.Context, key string) *redis.StringCmd
+ LLen(ctx context.Context, key string) *redis.IntCmd
+
+ // redis.StringCmdable
+ Decr(ctx context.Context, key string) *redis.IntCmd
+ Incr(ctx context.Context, key string) *redis.IntCmd
+ Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd
+ Get(ctx context.Context, key string) *redis.StringCmd
+
+ // redis.HashCmdable
+ HSet(ctx context.Context, key string, values ...any) *redis.IntCmd
+ HDel(ctx context.Context, key string, fields ...string) *redis.IntCmd
+ HKeys(ctx context.Context, key string) *redis.StringSliceCmd
+
+ // redis.SetCmdable
+ SAdd(ctx context.Context, key string, members ...any) *redis.IntCmd
+ SRem(ctx context.Context, key string, members ...any) *redis.IntCmd
+ SIsMember(ctx context.Context, key string, member any) *redis.BoolCmd
+
+ // redis.Cmdable
+ DBSize(ctx context.Context) *redis.IntCmd
+ FlushDB(ctx context.Context) *redis.StatusCmd
+ Ping(ctx context.Context) *redis.StatusCmd
+
+ // redis.UniversalClient
+ Close() error
+}
+
+type redisClientHolder struct {
+ RedisClient
+ name []string
+ count int64
+}
+
+func (r *redisClientHolder) Close() error {
+ return manager.CloseRedisClient(r.name[0])
+}
+
+type levelDBHolder struct {
+ name []string
+ count int64
+ db *leveldb.DB
+}
+
+func init() {
+ _ = GetManager()
+}
+
+// GetManager returns a Manager and initializes one as singleton is there's none yet
+func GetManager() *Manager {
+ if manager == nil {
+ ctx, _, finished := process.GetManager().AddTypedContext(context.Background(), "Service: NoSQL", process.SystemProcessType, false)
+ manager = &Manager{
+ ctx: ctx,
+ finished: finished,
+ RedisConnections: make(map[string]*redisClientHolder),
+ LevelDBConnections: make(map[string]*levelDBHolder),
+ }
+ }
+ return manager
+}
+
+func valToTimeDuration(vs []string) (result time.Duration) {
+ var err error
+ for _, v := range vs {
+ result, err = time.ParseDuration(v)
+ if err != nil {
+ var val int
+ val, err = strconv.Atoi(v)
+ result = time.Duration(val)
+ }
+ if err == nil {
+ return result
+ }
+ }
+ return result
+}
diff --git a/modules/nosql/manager_leveldb.go b/modules/nosql/manager_leveldb.go
new file mode 100644
index 0000000..4d2c90d
--- /dev/null
+++ b/modules/nosql/manager_leveldb.go
@@ -0,0 +1,214 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "fmt"
+ "path"
+ "runtime/pprof"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/syndtr/goleveldb/leveldb"
+ "github.com/syndtr/goleveldb/leveldb/errors"
+ "github.com/syndtr/goleveldb/leveldb/opt"
+)
+
+// CloseLevelDB closes a levelDB
+func (m *Manager) CloseLevelDB(connection string) error {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ db, ok := m.LevelDBConnections[connection]
+ if !ok {
+ // Try the full URI
+ uri := ToLevelDBURI(connection)
+ db, ok = m.LevelDBConnections[uri.String()]
+
+ if !ok {
+ // Try the datadir directly
+ dataDir := path.Join(uri.Host, uri.Path)
+
+ db, ok = m.LevelDBConnections[dataDir]
+ }
+ }
+ if !ok {
+ return nil
+ }
+
+ db.count--
+ if db.count > 0 {
+ return nil
+ }
+
+ for _, name := range db.name {
+ delete(m.LevelDBConnections, name)
+ }
+ return db.db.Close()
+}
+
+// GetLevelDB gets a levelDB for a particular connection
+func (m *Manager) GetLevelDB(connection string) (db *leveldb.DB, err error) {
+ // Because we want associate any goroutines created by this call to the main nosqldb context we need to
+ // wrap this in a goroutine labelled with the nosqldb context
+ done := make(chan struct{})
+ var recovered any
+ go func() {
+ defer func() {
+ recovered = recover()
+ if recovered != nil {
+ log.Critical("PANIC during GetLevelDB: %v\nStacktrace: %s", recovered, log.Stack(2))
+ }
+ close(done)
+ }()
+ pprof.SetGoroutineLabels(m.ctx)
+
+ db, err = m.getLevelDB(connection)
+ }()
+ <-done
+ if recovered != nil {
+ panic(recovered)
+ }
+ return db, err
+}
+
+func (m *Manager) getLevelDB(connection string) (*leveldb.DB, error) {
+ // Convert the provided connection description to the common format
+ uri := ToLevelDBURI(connection)
+
+ // Get the datadir
+ dataDir := path.Join(uri.Host, uri.Path)
+
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ db, ok := m.LevelDBConnections[connection]
+ if ok {
+ db.count++
+
+ return db.db, nil
+ }
+
+ db, ok = m.LevelDBConnections[uri.String()]
+ if ok {
+ db.count++
+
+ return db.db, nil
+ }
+
+ // if there is already a connection to this leveldb reuse that
+ // NOTE: if there differing options then only the first leveldb connection will be used
+ db, ok = m.LevelDBConnections[dataDir]
+ if ok {
+ db.count++
+ log.Warn("Duplicate connection to level db: %s with different connection strings. Initial connection: %s. This connection: %s", dataDir, db.name[0], connection)
+ db.name = append(db.name, connection)
+ m.LevelDBConnections[connection] = db
+ return db.db, nil
+ }
+ db = &levelDBHolder{
+ name: []string{connection, uri.String(), dataDir},
+ }
+
+ opts := &opt.Options{}
+ for k, v := range uri.Query() {
+ switch replacer.Replace(strings.ToLower(k)) {
+ case "blockcachecapacity":
+ opts.BlockCacheCapacity, _ = strconv.Atoi(v[0])
+ case "blockcacheevictremoved":
+ opts.BlockCacheEvictRemoved, _ = strconv.ParseBool(v[0])
+ case "blockrestartinterval":
+ opts.BlockRestartInterval, _ = strconv.Atoi(v[0])
+ case "blocksize":
+ opts.BlockSize, _ = strconv.Atoi(v[0])
+ case "compactionexpandlimitfactor":
+ opts.CompactionExpandLimitFactor, _ = strconv.Atoi(v[0])
+ case "compactiongpoverlapsfactor":
+ opts.CompactionGPOverlapsFactor, _ = strconv.Atoi(v[0])
+ case "compactionl0trigger":
+ opts.CompactionL0Trigger, _ = strconv.Atoi(v[0])
+ case "compactionsourcelimitfactor":
+ opts.CompactionSourceLimitFactor, _ = strconv.Atoi(v[0])
+ case "compactiontablesize":
+ opts.CompactionTableSize, _ = strconv.Atoi(v[0])
+ case "compactiontablesizemultiplier":
+ opts.CompactionTableSizeMultiplier, _ = strconv.ParseFloat(v[0], 64)
+ case "compactiontablesizemultiplierperlevel":
+ for _, val := range v {
+ f, _ := strconv.ParseFloat(val, 64)
+ opts.CompactionTableSizeMultiplierPerLevel = append(opts.CompactionTableSizeMultiplierPerLevel, f)
+ }
+ case "compactiontotalsize":
+ opts.CompactionTotalSize, _ = strconv.Atoi(v[0])
+ case "compactiontotalsizemultiplier":
+ opts.CompactionTotalSizeMultiplier, _ = strconv.ParseFloat(v[0], 64)
+ case "compactiontotalsizemultiplierperlevel":
+ for _, val := range v {
+ f, _ := strconv.ParseFloat(val, 64)
+ opts.CompactionTotalSizeMultiplierPerLevel = append(opts.CompactionTotalSizeMultiplierPerLevel, f)
+ }
+ case "compression":
+ val, _ := strconv.Atoi(v[0])
+ opts.Compression = opt.Compression(val)
+ case "disablebufferpool":
+ opts.DisableBufferPool, _ = strconv.ParseBool(v[0])
+ case "disableblockcache":
+ opts.DisableBlockCache, _ = strconv.ParseBool(v[0])
+ case "disablecompactionbackoff":
+ opts.DisableCompactionBackoff, _ = strconv.ParseBool(v[0])
+ case "disablelargebatchtransaction":
+ opts.DisableLargeBatchTransaction, _ = strconv.ParseBool(v[0])
+ case "errorifexist":
+ opts.ErrorIfExist, _ = strconv.ParseBool(v[0])
+ case "errorifmissing":
+ opts.ErrorIfMissing, _ = strconv.ParseBool(v[0])
+ case "iteratorsamplingrate":
+ opts.IteratorSamplingRate, _ = strconv.Atoi(v[0])
+ case "nosync":
+ opts.NoSync, _ = strconv.ParseBool(v[0])
+ case "nowritemerge":
+ opts.NoWriteMerge, _ = strconv.ParseBool(v[0])
+ case "openfilescachecapacity":
+ opts.OpenFilesCacheCapacity, _ = strconv.Atoi(v[0])
+ case "readonly":
+ opts.ReadOnly, _ = strconv.ParseBool(v[0])
+ case "strict":
+ val, _ := strconv.Atoi(v[0])
+ opts.Strict = opt.Strict(val)
+ case "writebuffer":
+ opts.WriteBuffer, _ = strconv.Atoi(v[0])
+ case "writel0pausetrigger":
+ opts.WriteL0PauseTrigger, _ = strconv.Atoi(v[0])
+ case "writel0slowdowntrigger":
+ opts.WriteL0SlowdownTrigger, _ = strconv.Atoi(v[0])
+ case "clientname":
+ db.name = append(db.name, v[0])
+ }
+ }
+
+ var err error
+ db.db, err = leveldb.OpenFile(dataDir, opts)
+ if err != nil {
+ if !errors.IsCorrupted(err) {
+ if strings.Contains(err.Error(), "resource temporarily unavailable") {
+ err = fmt.Errorf("unable to lock level db at %s: %w", dataDir, err)
+ return nil, err
+ }
+
+ err = fmt.Errorf("unable to open level db at %s: %w", dataDir, err)
+ return nil, err
+ }
+ db.db, err = leveldb.RecoverFile(dataDir, opts)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ for _, name := range db.name {
+ m.LevelDBConnections[name] = db
+ }
+ db.count++
+ return db.db, nil
+}
diff --git a/modules/nosql/manager_redis.go b/modules/nosql/manager_redis.go
new file mode 100644
index 0000000..79a533b
--- /dev/null
+++ b/modules/nosql/manager_redis.go
@@ -0,0 +1,258 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "crypto/tls"
+ "net/url"
+ "path"
+ "runtime/pprof"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/redis/go-redis/v9"
+)
+
+var replacer = strings.NewReplacer("_", "", "-", "")
+
+// CloseRedisClient closes a redis client
+func (m *Manager) CloseRedisClient(connection string) error {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ client, ok := m.RedisConnections[connection]
+ if !ok {
+ connection = ToRedisURI(connection).String()
+ client, ok = m.RedisConnections[connection]
+ }
+ if !ok {
+ return nil
+ }
+
+ client.count--
+ if client.count > 0 {
+ return nil
+ }
+
+ for _, name := range client.name {
+ delete(m.RedisConnections, name)
+ }
+ return client.RedisClient.Close()
+}
+
+// GetRedisClient gets a redis client for a particular connection
+func (m *Manager) GetRedisClient(connection string) (client RedisClient) {
+ // Because we want associate any goroutines created by this call to the main nosqldb context we need to
+ // wrap this in a goroutine labelled with the nosqldb context
+ done := make(chan struct{})
+ var recovered any
+ go func() {
+ defer func() {
+ recovered = recover()
+ if recovered != nil {
+ log.Critical("PANIC during GetRedisClient: %v\nStacktrace: %s", recovered, log.Stack(2))
+ }
+ close(done)
+ }()
+ pprof.SetGoroutineLabels(m.ctx)
+
+ client = m.getRedisClient(connection)
+ }()
+ <-done
+ if recovered != nil {
+ panic(recovered)
+ }
+ return client
+}
+
+func (m *Manager) getRedisClient(connection string) RedisClient {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ client, ok := m.RedisConnections[connection]
+ if ok {
+ client.count++
+ return client
+ }
+
+ uri := ToRedisURI(connection)
+ client, ok = m.RedisConnections[uri.String()]
+ if ok {
+ client.count++
+ return client
+ }
+ client = &redisClientHolder{
+ name: []string{connection, uri.String()},
+ }
+
+ opts := getRedisOptions(uri)
+ tlsConfig := getRedisTLSOptions(uri)
+
+ clientName := uri.Query().Get("clientname")
+
+ if len(clientName) > 0 {
+ client.name = append(client.name, clientName)
+ }
+
+ switch uri.Scheme {
+ case "redis+sentinels":
+ fallthrough
+ case "rediss+sentinel":
+ opts.TLSConfig = tlsConfig
+ fallthrough
+ case "redis+sentinel":
+ client.RedisClient = redis.NewFailoverClient(opts.Failover())
+ case "redis+clusters":
+ fallthrough
+ case "rediss+cluster":
+ opts.TLSConfig = tlsConfig
+ fallthrough
+ case "redis+cluster":
+ client.RedisClient = redis.NewClusterClient(opts.Cluster())
+ case "redis+socket":
+ simpleOpts := opts.Simple()
+ simpleOpts.Network = "unix"
+ simpleOpts.Addr = path.Join(uri.Host, uri.Path)
+ client.RedisClient = redis.NewClient(simpleOpts)
+ case "rediss":
+ opts.TLSConfig = tlsConfig
+ fallthrough
+ case "redis":
+ client.RedisClient = redis.NewClient(opts.Simple())
+ default:
+ return nil
+ }
+
+ for _, name := range client.name {
+ m.RedisConnections[name] = client
+ }
+
+ client.count++
+
+ return client
+}
+
+// getRedisOptions pulls various configuration options based on the RedisUri format and converts them to go-redis's
+// UniversalOptions fields. This function explicitly excludes fields related to TLS configuration, which is
+// conditionally attached to this options struct before being converted to the specific type for the redis scheme being
+// used, and only in scenarios where TLS is applicable (e.g. rediss://, redis+clusters://).
+func getRedisOptions(uri *url.URL) *redis.UniversalOptions {
+ opts := &redis.UniversalOptions{}
+
+ // Handle username/password
+ if password, ok := uri.User.Password(); ok {
+ opts.Password = password
+ // Username does not appear to be handled by redis.Options
+ opts.Username = uri.User.Username()
+ } else if uri.User.Username() != "" {
+ // assume this is the password
+ opts.Password = uri.User.Username()
+ }
+
+ // Now handle the uri query sets
+ for k, v := range uri.Query() {
+ switch replacer.Replace(strings.ToLower(k)) {
+ case "addr":
+ opts.Addrs = append(opts.Addrs, v...)
+ case "addrs":
+ opts.Addrs = append(opts.Addrs, strings.Split(v[0], ",")...)
+ case "username":
+ opts.Username = v[0]
+ case "password":
+ opts.Password = v[0]
+ case "database":
+ fallthrough
+ case "db":
+ opts.DB, _ = strconv.Atoi(v[0])
+ case "maxretries":
+ opts.MaxRetries, _ = strconv.Atoi(v[0])
+ case "minretrybackoff":
+ opts.MinRetryBackoff = valToTimeDuration(v)
+ case "maxretrybackoff":
+ opts.MaxRetryBackoff = valToTimeDuration(v)
+ case "timeout":
+ timeout := valToTimeDuration(v)
+ if timeout != 0 {
+ if opts.DialTimeout == 0 {
+ opts.DialTimeout = timeout
+ }
+ if opts.ReadTimeout == 0 {
+ opts.ReadTimeout = timeout
+ }
+ }
+ case "dialtimeout":
+ opts.DialTimeout = valToTimeDuration(v)
+ case "readtimeout":
+ opts.ReadTimeout = valToTimeDuration(v)
+ case "writetimeout":
+ opts.WriteTimeout = valToTimeDuration(v)
+ case "poolsize":
+ opts.PoolSize, _ = strconv.Atoi(v[0])
+ case "minidleconns":
+ opts.MinIdleConns, _ = strconv.Atoi(v[0])
+ case "pooltimeout":
+ opts.PoolTimeout = valToTimeDuration(v)
+ case "maxredirects":
+ opts.MaxRedirects, _ = strconv.Atoi(v[0])
+ case "readonly":
+ opts.ReadOnly, _ = strconv.ParseBool(v[0])
+ case "routebylatency":
+ opts.RouteByLatency, _ = strconv.ParseBool(v[0])
+ case "routerandomly":
+ opts.RouteRandomly, _ = strconv.ParseBool(v[0])
+ case "sentinelmasterid":
+ fallthrough
+ case "mastername":
+ opts.MasterName = v[0]
+ case "sentinelusername":
+ opts.SentinelUsername = v[0]
+ case "sentinelpassword":
+ opts.SentinelPassword = v[0]
+ }
+ }
+
+ if uri.Host != "" {
+ opts.Addrs = append(opts.Addrs, strings.Split(uri.Host, ",")...)
+ }
+
+ // A redis connection string uses the path section of the URI in two different ways. In a TCP-based connection, the
+ // path will be a database index to automatically have the client SELECT. In a Unix socket connection, it will be the
+ // file path. We only want to try to coerce this to the database index when we're not expecting a file path so that
+ // the error log stays clean.
+ if uri.Path != "" && uri.Scheme != "redis+socket" {
+ if db, err := strconv.Atoi(uri.Path[1:]); err == nil {
+ opts.DB = db
+ } else {
+ log.Error("Provided database identifier '%s' is not a valid integer. Forgejo will ignore this option.", uri.Path)
+ }
+ }
+
+ return opts
+}
+
+// getRedisTlsOptions parses RedisUri TLS configuration parameters and converts them to the go TLS configuration
+// equivalent fields.
+func getRedisTLSOptions(uri *url.URL) *tls.Config {
+ tlsConfig := &tls.Config{}
+
+ skipverify := uri.Query().Get("skipverify")
+
+ if len(skipverify) > 0 {
+ skipverify, err := strconv.ParseBool(skipverify)
+ if err == nil {
+ tlsConfig.InsecureSkipVerify = skipverify
+ }
+ }
+
+ insecureskipverify := uri.Query().Get("insecureskipverify")
+
+ if len(insecureskipverify) > 0 {
+ insecureskipverify, err := strconv.ParseBool(insecureskipverify)
+ if err == nil {
+ tlsConfig.InsecureSkipVerify = insecureskipverify
+ }
+ }
+
+ return tlsConfig
+}
diff --git a/modules/nosql/manager_redis_test.go b/modules/nosql/manager_redis_test.go
new file mode 100644
index 0000000..d979ea0
--- /dev/null
+++ b/modules/nosql/manager_redis_test.go
@@ -0,0 +1,81 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestRedisUsernameOpt(t *testing.T) {
+ uri, _ := url.Parse("redis://redis:password@myredis/0")
+ opts := getRedisOptions(uri)
+
+ if opts.Username != "redis" {
+ t.Fail()
+ }
+}
+
+func TestRedisPasswordOpt(t *testing.T) {
+ uri, _ := url.Parse("redis://redis:password@myredis/0")
+ opts := getRedisOptions(uri)
+
+ if opts.Password != "password" {
+ t.Fail()
+ }
+}
+
+func TestSkipVerifyOpt(t *testing.T) {
+ uri, _ := url.Parse("rediss://myredis/0?skipverify=true")
+ tlsConfig := getRedisTLSOptions(uri)
+
+ if !tlsConfig.InsecureSkipVerify {
+ t.Fail()
+ }
+}
+
+func TestInsecureSkipVerifyOpt(t *testing.T) {
+ uri, _ := url.Parse("rediss://myredis/0?insecureskipverify=true")
+ tlsConfig := getRedisTLSOptions(uri)
+
+ if !tlsConfig.InsecureSkipVerify {
+ t.Fail()
+ }
+}
+
+func TestRedisSentinelUsernameOpt(t *testing.T) {
+ uri, _ := url.Parse("redis+sentinel://redis:password@myredis/0?sentinelusername=suser&sentinelpassword=spass")
+ opts := getRedisOptions(uri).Failover()
+
+ if opts.SentinelUsername != "suser" {
+ t.Fail()
+ }
+}
+
+func TestRedisSentinelPasswordOpt(t *testing.T) {
+ uri, _ := url.Parse("redis+sentinel://redis:password@myredis/0?sentinelusername=suser&sentinelpassword=spass")
+ opts := getRedisOptions(uri).Failover()
+
+ if opts.SentinelPassword != "spass" {
+ t.Fail()
+ }
+}
+
+func TestRedisDatabaseIndexTcp(t *testing.T) {
+ uri, _ := url.Parse("redis://redis:password@myredis/12")
+ opts := getRedisOptions(uri)
+
+ if opts.DB != 12 {
+ t.Fail()
+ }
+}
+
+func TestRedisDatabaseIndexUnix(t *testing.T) {
+ uri, _ := url.Parse("redis+socket:///var/run/redis.sock?database=12")
+ opts := getRedisOptions(uri)
+
+ if opts.DB != 12 {
+ t.Fail()
+ }
+}
diff --git a/modules/nosql/redis.go b/modules/nosql/redis.go
new file mode 100644
index 0000000..52e8ff9
--- /dev/null
+++ b/modules/nosql/redis.go
@@ -0,0 +1,100 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// The file contains common redis connection functions
+
+// ToRedisURI converts old style connections to a RedisURI
+//
+// A RedisURI matches the pattern:
+//
+// redis://[username:password@]host[:port][/database][?[option=value]*]
+// rediss://[username:password@]host[:port][/database][?[option=value]*]
+// redis+socket://[username:password@]path[/database][?[option=value]*]
+// redis+sentinel://[password@]host1 [: port1][, host2 [:port2]][, hostN [:portN]][/ database][?[option=value]*]
+// redis+cluster://[password@]host1 [: port1][, host2 [:port2]][, hostN [:portN]][/ database][?[option=value]*]
+//
+// We have previously used a URI like:
+// addrs=127.0.0.1:6379 db=0
+// network=tcp,addr=127.0.0.1:6379,password=macaron,db=0,pool_size=100,idle_timeout=180
+//
+// We need to convert this old style to the new style
+func ToRedisURI(connection string) *url.URL {
+ uri, err := url.Parse(connection)
+ if err == nil && strings.HasPrefix(uri.Scheme, "redis") {
+ // OK we're going to assume that this is a reasonable redis URI
+ return uri
+ }
+
+ // Let's set a nice default
+ uri, _ = url.Parse("redis://127.0.0.1:6379/0")
+ network := "tcp"
+ query := uri.Query()
+
+ // OK so there are two types: Space delimited and Comma delimited
+ // Let's assume that we have a space delimited string - as this is the most common
+ fields := strings.Fields(connection)
+ if len(fields) == 1 {
+ // It's a comma delimited string, then...
+ fields = strings.Split(connection, ",")
+ }
+ for _, f := range fields {
+ items := strings.SplitN(f, "=", 2)
+ if len(items) < 2 {
+ continue
+ }
+ switch strings.ToLower(items[0]) {
+ case "network":
+ if items[1] == "unix" {
+ uri.Scheme = "redis+socket"
+ }
+ network = items[1]
+ case "addrs":
+ uri.Host = items[1]
+ // now we need to handle the clustering
+ if strings.Contains(items[1], ",") && network == "tcp" {
+ uri.Scheme = "redis+cluster"
+ }
+ case "addr":
+ uri.Host = items[1]
+ case "password":
+ uri.User = url.UserPassword(uri.User.Username(), items[1])
+ case "username":
+ password, set := uri.User.Password()
+ if !set {
+ uri.User = url.User(items[1])
+ } else {
+ uri.User = url.UserPassword(items[1], password)
+ }
+ case "db":
+ uri.Path = "/" + items[1]
+ case "idle_timeout":
+ _, err := strconv.Atoi(items[1])
+ if err == nil {
+ query.Add("idle_timeout", items[1]+"s")
+ } else {
+ query.Add("idle_timeout", items[1])
+ }
+ default:
+ // Other options become query params
+ query.Add(items[0], items[1])
+ }
+ }
+
+ // Finally we need to fix up the Host if we have a unix port
+ if uri.Scheme == "redis+socket" {
+ query.Set("db", uri.Path)
+ uri.Path = uri.Host
+ uri.Host = ""
+ }
+ uri.RawQuery = query.Encode()
+
+ return uri
+}
diff --git a/modules/nosql/redis_test.go b/modules/nosql/redis_test.go
new file mode 100644
index 0000000..43652e3
--- /dev/null
+++ b/modules/nosql/redis_test.go
@@ -0,0 +1,34 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nosql
+
+import (
+ "testing"
+)
+
+func TestToRedisURI(t *testing.T) {
+ tests := []struct {
+ name string
+ connection string
+ want string
+ }{
+ {
+ name: "old_default",
+ connection: "addrs=127.0.0.1:6379 db=0",
+ want: "redis://127.0.0.1:6379/0",
+ },
+ {
+ name: "old_macaron_session_default",
+ connection: "network=tcp,addr=127.0.0.1:6379,password=macaron,db=0,pool_size=100,idle_timeout=180",
+ want: "redis://:macaron@127.0.0.1:6379/0?idle_timeout=180s&pool_size=100",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ToRedisURI(tt.connection); got == nil || got.String() != tt.want {
+ t.Errorf(`ToRedisURI(%q) = %s, want %s`, tt.connection, got.String(), tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/optional/option.go b/modules/optional/option.go
new file mode 100644
index 0000000..af9e5ac
--- /dev/null
+++ b/modules/optional/option.go
@@ -0,0 +1,45 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package optional
+
+type Option[T any] []T
+
+func None[T any]() Option[T] {
+ return nil
+}
+
+func Some[T any](v T) Option[T] {
+ return Option[T]{v}
+}
+
+func FromPtr[T any](v *T) Option[T] {
+ if v == nil {
+ return None[T]()
+ }
+ return Some(*v)
+}
+
+func FromNonDefault[T comparable](v T) Option[T] {
+ var zero T
+ if v == zero {
+ return None[T]()
+ }
+ return Some(v)
+}
+
+func (o Option[T]) Has() bool {
+ return o != nil
+}
+
+func (o Option[T]) Value() T {
+ var zero T
+ return o.ValueOrDefault(zero)
+}
+
+func (o Option[T]) ValueOrDefault(v T) T {
+ if o.Has() {
+ return o[0]
+ }
+ return v
+}
diff --git a/modules/optional/option_test.go b/modules/optional/option_test.go
new file mode 100644
index 0000000..203e922
--- /dev/null
+++ b/modules/optional/option_test.go
@@ -0,0 +1,59 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package optional_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/optional"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestOption(t *testing.T) {
+ var uninitialized optional.Option[int]
+ assert.False(t, uninitialized.Has())
+ assert.Equal(t, int(0), uninitialized.Value())
+ assert.Equal(t, int(1), uninitialized.ValueOrDefault(1))
+
+ none := optional.None[int]()
+ assert.False(t, none.Has())
+ assert.Equal(t, int(0), none.Value())
+ assert.Equal(t, int(1), none.ValueOrDefault(1))
+
+ some := optional.Some(1)
+ assert.True(t, some.Has())
+ assert.Equal(t, int(1), some.Value())
+ assert.Equal(t, int(1), some.ValueOrDefault(2))
+
+ noneBool := optional.None[bool]()
+ assert.False(t, noneBool.Has())
+ assert.False(t, noneBool.Value())
+ assert.True(t, noneBool.ValueOrDefault(true))
+
+ someBool := optional.Some(true)
+ assert.True(t, someBool.Has())
+ assert.True(t, someBool.Value())
+ assert.True(t, someBool.ValueOrDefault(false))
+
+ var ptr *int
+ assert.False(t, optional.FromPtr(ptr).Has())
+
+ int1 := 1
+ opt1 := optional.FromPtr(&int1)
+ assert.True(t, opt1.Has())
+ assert.Equal(t, int(1), opt1.Value())
+
+ assert.False(t, optional.FromNonDefault("").Has())
+
+ opt2 := optional.FromNonDefault("test")
+ assert.True(t, opt2.Has())
+ assert.Equal(t, "test", opt2.Value())
+
+ assert.False(t, optional.FromNonDefault(0).Has())
+
+ opt3 := optional.FromNonDefault(1)
+ assert.True(t, opt3.Has())
+ assert.Equal(t, int(1), opt3.Value())
+}
diff --git a/modules/optional/serialization.go b/modules/optional/serialization.go
new file mode 100644
index 0000000..b120a0e
--- /dev/null
+++ b/modules/optional/serialization.go
@@ -0,0 +1,46 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package optional
+
+import (
+ "code.gitea.io/gitea/modules/json"
+
+ "gopkg.in/yaml.v3"
+)
+
+func (o *Option[T]) UnmarshalJSON(data []byte) error {
+ var v *T
+ if err := json.Unmarshal(data, &v); err != nil {
+ return err
+ }
+ *o = FromPtr(v)
+ return nil
+}
+
+func (o Option[T]) MarshalJSON() ([]byte, error) {
+ if !o.Has() {
+ return []byte("null"), nil
+ }
+
+ return json.Marshal(o.Value())
+}
+
+func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {
+ var v *T
+ if err := value.Decode(&v); err != nil {
+ return err
+ }
+ *o = FromPtr(v)
+ return nil
+}
+
+func (o Option[T]) MarshalYAML() (any, error) {
+ if !o.Has() {
+ return nil, nil
+ }
+
+ value := new(yaml.Node)
+ err := value.Encode(o.Value())
+ return value, err
+}
diff --git a/modules/optional/serialization_test.go b/modules/optional/serialization_test.go
new file mode 100644
index 0000000..c852b8a
--- /dev/null
+++ b/modules/optional/serialization_test.go
@@ -0,0 +1,191 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package optional_test
+
+import (
+ std_json "encoding/json" //nolint:depguard
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/optional"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+type testSerializationStruct struct {
+ NormalString string `json:"normal_string" yaml:"normal_string"`
+ NormalBool bool `json:"normal_bool" yaml:"normal_bool"`
+ OptBool optional.Option[bool] `json:"optional_bool,omitempty" yaml:"optional_bool,omitempty"`
+ OptString optional.Option[string] `json:"optional_string,omitempty" yaml:"optional_string,omitempty"`
+ OptTwoBool optional.Option[bool] `json:"optional_two_bool" yaml:"optional_two_bool"`
+ OptTwoString optional.Option[string] `json:"optional_twostring" yaml:"optional_two_string"`
+}
+
+func TestOptionalToJson(t *testing.T) {
+ tests := []struct {
+ name string
+ obj *testSerializationStruct
+ want string
+ }{
+ {
+ name: "empty",
+ obj: new(testSerializationStruct),
+ want: `{"normal_string":"","normal_bool":false,"optional_two_bool":null,"optional_twostring":null}`,
+ },
+ {
+ name: "some",
+ obj: &testSerializationStruct{
+ NormalString: "a string",
+ NormalBool: true,
+ OptBool: optional.Some(false),
+ OptString: optional.Some(""),
+ OptTwoBool: optional.None[bool](),
+ OptTwoString: optional.None[string](),
+ },
+ want: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ b, err := json.Marshal(tc.obj)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, string(b), "gitea json module returned unexpected")
+
+ b, err = std_json.Marshal(tc.obj)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, string(b), "std json module returned unexpected")
+ })
+ }
+}
+
+func TestOptionalFromJson(t *testing.T) {
+ tests := []struct {
+ name string
+ data string
+ want testSerializationStruct
+ }{
+ {
+ name: "empty",
+ data: `{}`,
+ want: testSerializationStruct{
+ NormalString: "",
+ },
+ },
+ {
+ name: "some",
+ data: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`,
+ want: testSerializationStruct{
+ NormalString: "a string",
+ NormalBool: true,
+ OptBool: optional.Some(false),
+ OptString: optional.Some(""),
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var obj1 testSerializationStruct
+ err := json.Unmarshal([]byte(tc.data), &obj1)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, obj1, "gitea json module returned unexpected")
+
+ var obj2 testSerializationStruct
+ err = std_json.Unmarshal([]byte(tc.data), &obj2)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, obj2, "std json module returned unexpected")
+ })
+ }
+}
+
+func TestOptionalToYaml(t *testing.T) {
+ tests := []struct {
+ name string
+ obj *testSerializationStruct
+ want string
+ }{
+ {
+ name: "empty",
+ obj: new(testSerializationStruct),
+ want: `normal_string: ""
+normal_bool: false
+optional_two_bool: null
+optional_two_string: null
+`,
+ },
+ {
+ name: "some",
+ obj: &testSerializationStruct{
+ NormalString: "a string",
+ NormalBool: true,
+ OptBool: optional.Some(false),
+ OptString: optional.Some(""),
+ },
+ want: `normal_string: a string
+normal_bool: true
+optional_bool: false
+optional_string: ""
+optional_two_bool: null
+optional_two_string: null
+`,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ b, err := yaml.Marshal(tc.obj)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, string(b), "yaml module returned unexpected")
+ })
+ }
+}
+
+func TestOptionalFromYaml(t *testing.T) {
+ tests := []struct {
+ name string
+ data string
+ want testSerializationStruct
+ }{
+ {
+ name: "empty",
+ data: ``,
+ want: testSerializationStruct{},
+ },
+ {
+ name: "empty but init",
+ data: `normal_string: ""
+normal_bool: false
+optional_bool:
+optional_two_bool:
+optional_two_string:
+`,
+ want: testSerializationStruct{},
+ },
+ {
+ name: "some",
+ data: `
+normal_string: a string
+normal_bool: true
+optional_bool: false
+optional_string: ""
+optional_two_bool: null
+optional_twostring: null
+`,
+ want: testSerializationStruct{
+ NormalString: "a string",
+ NormalBool: true,
+ OptBool: optional.Some(false),
+ OptString: optional.Some(""),
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var obj testSerializationStruct
+ err := yaml.Unmarshal([]byte(tc.data), &obj)
+ require.NoError(t, err)
+ assert.EqualValues(t, tc.want, obj, "yaml module returned unexpected")
+ })
+ }
+}
diff --git a/modules/options/base.go b/modules/options/base.go
new file mode 100644
index 0000000..6c6e383
--- /dev/null
+++ b/modules/options/base.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package options
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func CustomAssets() *assetfs.Layer {
+ return assetfs.Local("custom", setting.CustomPath, "options")
+}
+
+func AssetFS() *assetfs.LayeredFS {
+ return assetfs.Layered(CustomAssets(), BuiltinAssets())
+}
+
+// Locale reads the content of a specific locale from static/bindata or custom path.
+func Locale(name string) ([]byte, error) {
+ return AssetFS().ReadFile("locale", name)
+}
+
+// Readme reads the content of a specific readme from static/bindata or custom path.
+func Readme(name string) ([]byte, error) {
+ return AssetFS().ReadFile("readme", name)
+}
+
+// Gitignore reads the content of a gitignore locale from static/bindata or custom path.
+func Gitignore(name string) ([]byte, error) {
+ return AssetFS().ReadFile("gitignore", name)
+}
+
+// License reads the content of a specific license from static/bindata or custom path.
+func License(name string) ([]byte, error) {
+ return AssetFS().ReadFile("license", name)
+}
+
+// Labels reads the content of a specific labels from static/bindata or custom path.
+func Labels(name string) ([]byte, error) {
+ return AssetFS().ReadFile("label", name)
+}
diff --git a/modules/options/dynamic.go b/modules/options/dynamic.go
new file mode 100644
index 0000000..085492d
--- /dev/null
+++ b/modules/options/dynamic.go
@@ -0,0 +1,15 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package options
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Local("builtin(static)", setting.StaticRootPath, "options")
+}
diff --git a/modules/options/options_bindata.go b/modules/options/options_bindata.go
new file mode 100644
index 0000000..29151cb
--- /dev/null
+++ b/modules/options/options_bindata.go
@@ -0,0 +1,8 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package options
+
+//go:generate go run ../../build/generate-bindata.go ../../options options bindata.go
diff --git a/modules/options/static.go b/modules/options/static.go
new file mode 100644
index 0000000..72b28e9
--- /dev/null
+++ b/modules/options/static.go
@@ -0,0 +1,14 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package options
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+)
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Bindata("builtin(bindata)", Assets)
+}
diff --git a/modules/packages/alpine/metadata.go b/modules/packages/alpine/metadata.go
new file mode 100644
index 0000000..582c426
--- /dev/null
+++ b/modules/packages/alpine/metadata.go
@@ -0,0 +1,242 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package alpine
+
+import (
+ "archive/tar"
+ "bufio"
+ "compress/gzip"
+ "crypto/sha1"
+ "encoding/base64"
+ "io"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+)
+
+var (
+ ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+)
+
+const (
+ PropertyMetadata = "alpine.metadata"
+ PropertyBranch = "alpine.branch"
+ PropertyRepository = "alpine.repository"
+ PropertyArchitecture = "alpine.architecture"
+
+ SettingKeyPrivate = "alpine.key.private"
+ SettingKeyPublic = "alpine.key.public"
+
+ RepositoryPackage = "_alpine"
+ RepositoryVersion = "_repository"
+)
+
+// https://wiki.alpinelinux.org/wiki/Apk_spec
+
+// Package represents an Alpine package
+type Package struct {
+ Name string
+ Version string
+ VersionMetadata VersionMetadata
+ FileMetadata FileMetadata
+}
+
+// Metadata of an Alpine package
+type VersionMetadata struct {
+ Description string `json:"description,omitempty"`
+ License string `json:"license,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Maintainer string `json:"maintainer,omitempty"`
+}
+
+type FileMetadata struct {
+ Checksum string `json:"checksum"`
+ Packager string `json:"packager,omitempty"`
+ BuildDate int64 `json:"build_date,omitempty"`
+ Size int64 `json:"size,omitempty"`
+ Architecture string `json:"architecture,omitempty"`
+ Origin string `json:"origin,omitempty"`
+ CommitHash string `json:"commit_hash,omitempty"`
+ InstallIf string `json:"install_if,omitempty"`
+ Provides []string `json:"provides,omitempty"`
+ Dependencies []string `json:"dependencies,omitempty"`
+ ProviderPriority int64 `json:"provider_priority,omitempty"`
+}
+
+// ParsePackage parses the Alpine package file
+func ParsePackage(r io.Reader) (*Package, error) {
+ // Alpine packages are concated .tar.gz streams. Usually the first stream contains the package metadata.
+
+ br := bufio.NewReader(r) // needed for gzip Multistream
+
+ h := sha1.New()
+
+ gzr, err := gzip.NewReader(&teeByteReader{br, h})
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ for {
+ gzr.Multistream(false)
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Name == ".PKGINFO" {
+ p, err := ParsePackageInfo(tr)
+ if err != nil {
+ return nil, err
+ }
+
+ // drain the reader
+ for {
+ if _, err := tr.Next(); err != nil {
+ break
+ }
+ }
+
+ p.FileMetadata.Checksum = "Q1" + base64.StdEncoding.EncodeToString(h.Sum(nil))
+
+ return p, nil
+ }
+ }
+
+ h = sha1.New()
+
+ err = gzr.Reset(&teeByteReader{br, h})
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return nil, ErrMissingPKGINFOFile
+}
+
+// ParsePackageInfo parses a PKGINFO file to retrieve the metadata of an Alpine package
+func ParsePackageInfo(r io.Reader) (*Package, error) {
+ p := &Package{}
+
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ i := strings.IndexRune(line, '=')
+ if i == -1 {
+ continue
+ }
+
+ key := strings.TrimSpace(line[:i])
+ value := strings.TrimSpace(line[i+1:])
+
+ switch key {
+ case "pkgname":
+ p.Name = value
+ case "pkgver":
+ p.Version = value
+ case "pkgdesc":
+ p.VersionMetadata.Description = value
+ case "url":
+ p.VersionMetadata.ProjectURL = value
+ case "builddate":
+ n, err := strconv.ParseInt(value, 10, 64)
+ if err == nil {
+ p.FileMetadata.BuildDate = n
+ }
+ case "size":
+ n, err := strconv.ParseInt(value, 10, 64)
+ if err == nil {
+ p.FileMetadata.Size = n
+ }
+ case "arch":
+ p.FileMetadata.Architecture = value
+ case "origin":
+ p.FileMetadata.Origin = value
+ case "commit":
+ p.FileMetadata.CommitHash = value
+ case "maintainer":
+ p.VersionMetadata.Maintainer = value
+ case "packager":
+ p.FileMetadata.Packager = value
+ case "license":
+ p.VersionMetadata.License = value
+ case "install_if":
+ p.FileMetadata.InstallIf = value
+ case "provides":
+ if value != "" {
+ p.FileMetadata.Provides = append(p.FileMetadata.Provides, value)
+ }
+ case "depend":
+ if value != "" {
+ p.FileMetadata.Dependencies = append(p.FileMetadata.Dependencies, value)
+ }
+ case "provider_priority":
+ n, err := strconv.ParseInt(value, 10, 64)
+ if err == nil {
+ p.FileMetadata.ProviderPriority = n
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ if p.Name == "" {
+ return nil, ErrInvalidName
+ }
+
+ if p.Version == "" {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(p.VersionMetadata.ProjectURL) {
+ p.VersionMetadata.ProjectURL = ""
+ }
+
+ return p, nil
+}
+
+// Same as io.TeeReader but implements io.ByteReader
+type teeByteReader struct {
+ r *bufio.Reader
+ w io.Writer
+}
+
+func (t *teeByteReader) Read(p []byte) (int, error) {
+ n, err := t.r.Read(p)
+ if n > 0 {
+ if n, err := t.w.Write(p[:n]); err != nil {
+ return n, err
+ }
+ }
+ return n, err
+}
+
+func (t *teeByteReader) ReadByte() (byte, error) {
+ b, err := t.r.ReadByte()
+ if err == nil {
+ if _, err := t.w.Write([]byte{b}); err != nil {
+ return 0, err
+ }
+ }
+ return b, err
+}
diff --git a/modules/packages/alpine/metadata_test.go b/modules/packages/alpine/metadata_test.go
new file mode 100644
index 0000000..8167b49
--- /dev/null
+++ b/modules/packages/alpine/metadata_test.go
@@ -0,0 +1,144 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package alpine
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ packageDescription = "Package Description"
+ packageProjectURL = "https://gitea.io"
+ packageMaintainer = "KN4CK3R <dummy@gitea.io>"
+)
+
+func createPKGINFOContent(name, version string) []byte {
+ return []byte(`pkgname = ` + name + `
+pkgver = ` + version + `
+pkgdesc = ` + packageDescription + `
+url = ` + packageProjectURL + `
+# comment
+builddate = 1678834800
+packager = Gitea <pack@ag.er>
+size = 123456
+arch = aarch64
+origin = origin
+commit = 1111e709613fbc979651b09ac2bc27c6591a9999
+maintainer = ` + packageMaintainer + `
+license = MIT
+depend = common
+install_if = value
+depend = gitea
+provides = common
+provides = gitea`)
+}
+
+func TestParsePackage(t *testing.T) {
+ createPackage := func(name string, content []byte) io.Reader {
+ names := []string{"first.stream", name}
+ contents := [][]byte{{0}, content}
+
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+
+ for i := range names {
+ if i != 0 {
+ zw.Close()
+ zw.Reset(&buf)
+ }
+
+ tw := tar.NewWriter(zw)
+ hdr := &tar.Header{
+ Name: names[i],
+ Mode: 0o600,
+ Size: int64(len(contents[i])),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(contents[i])
+ tw.Close()
+ }
+
+ zw.Close()
+
+ return &buf
+ }
+
+ t.Run("MissingPKGINFOFile", func(t *testing.T) {
+ data := createPackage("dummy.txt", []byte{})
+
+ pp, err := ParsePackage(data)
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrMissingPKGINFOFile)
+ })
+
+ t.Run("InvalidPKGINFOFile", func(t *testing.T) {
+ data := createPackage(".PKGINFO", []byte{})
+
+ pp, err := ParsePackage(data)
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrInvalidName)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createPackage(".PKGINFO", createPKGINFOContent(packageName, packageVersion))
+
+ p, err := ParsePackage(data)
+ require.NoError(t, err)
+ assert.NotNil(t, p)
+
+ assert.Equal(t, "Q1SRYURM5+uQDqfHSwTnNIOIuuDVQ=", p.FileMetadata.Checksum)
+ })
+}
+
+func TestParsePackageInfo(t *testing.T) {
+ t.Run("InvalidName", func(t *testing.T) {
+ data := createPKGINFOContent("", packageVersion)
+
+ p, err := ParsePackageInfo(bytes.NewReader(data))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidName)
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ data := createPKGINFOContent(packageName, "")
+
+ p, err := ParsePackageInfo(bytes.NewReader(data))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createPKGINFOContent(packageName, packageVersion)
+
+ p, err := ParsePackageInfo(bytes.NewReader(data))
+ require.NoError(t, err)
+ assert.NotNil(t, p)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, packageDescription, p.VersionMetadata.Description)
+ assert.Equal(t, packageMaintainer, p.VersionMetadata.Maintainer)
+ assert.Equal(t, packageProjectURL, p.VersionMetadata.ProjectURL)
+ assert.Equal(t, "MIT", p.VersionMetadata.License)
+ assert.Empty(t, p.FileMetadata.Checksum)
+ assert.Equal(t, "Gitea <pack@ag.er>", p.FileMetadata.Packager)
+ assert.EqualValues(t, 1678834800, p.FileMetadata.BuildDate)
+ assert.EqualValues(t, 123456, p.FileMetadata.Size)
+ assert.Equal(t, "aarch64", p.FileMetadata.Architecture)
+ assert.Equal(t, "origin", p.FileMetadata.Origin)
+ assert.Equal(t, "1111e709613fbc979651b09ac2bc27c6591a9999", p.FileMetadata.CommitHash)
+ assert.Equal(t, "value", p.FileMetadata.InstallIf)
+ assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Provides)
+ assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Dependencies)
+ })
+}
diff --git a/modules/packages/arch/metadata.go b/modules/packages/arch/metadata.go
new file mode 100644
index 0000000..6cdde75
--- /dev/null
+++ b/modules/packages/arch/metadata.go
@@ -0,0 +1,341 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/packages"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/mholt/archiver/v3"
+)
+
+// Arch Linux Packages
+// https://man.archlinux.org/man/PKGBUILD.5
+
+const (
+ PropertyDescription = "arch.description"
+ PropertyArch = "arch.architecture"
+ PropertyDistribution = "arch.distribution"
+
+ SettingKeyPrivate = "arch.key.private"
+ SettingKeyPublic = "arch.key.public"
+
+ RepositoryPackage = "_arch"
+ RepositoryVersion = "_repository"
+)
+
+var (
+ reName = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+$`)
+ reVer = regexp.MustCompile(`^[a-zA-Z0-9:_.+]+-+[0-9]+$`)
+ reOptDep = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+([<>]?=?([0-9]+:)?[a-zA-Z0-9@._+-]+)?(:.*)?$`)
+ rePkgVer = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+([<>]?=?([0-9]+:)?[a-zA-Z0-9@._+-]+)?$`)
+
+ magicZSTD = []byte{0x28, 0xB5, 0x2F, 0xFD}
+ magicXZ = []byte{0xFD, 0x37, 0x7A, 0x58, 0x5A}
+ magicGZ = []byte{0x1F, 0x8B}
+)
+
+type Package struct {
+ Name string `json:"name"`
+ Version string `json:"version"` // Includes version, release and epoch
+ CompressType string `json:"compress_type"`
+ VersionMetadata VersionMetadata
+ FileMetadata FileMetadata
+}
+
+// Arch package metadata related to specific version.
+// Version metadata the same across different architectures and distributions.
+type VersionMetadata struct {
+ Base string `json:"base"`
+ Description string `json:"description"`
+ ProjectURL string `json:"project_url"`
+ Groups []string `json:"groups,omitempty"`
+ Provides []string `json:"provides,omitempty"`
+ License []string `json:"license,omitempty"`
+ Depends []string `json:"depends,omitempty"`
+ OptDepends []string `json:"opt_depends,omitempty"`
+ MakeDepends []string `json:"make_depends,omitempty"`
+ CheckDepends []string `json:"check_depends,omitempty"`
+ Conflicts []string `json:"conflicts,omitempty"`
+ Replaces []string `json:"replaces,omitempty"`
+ Backup []string `json:"backup,omitempty"`
+ XData []string `json:"xdata,omitempty"`
+}
+
+// FileMetadata Metadata related to specific package file.
+// This metadata might vary for different architecture and distribution.
+type FileMetadata struct {
+ CompressedSize int64 `json:"compressed_size"`
+ InstalledSize int64 `json:"installed_size"`
+ MD5 string `json:"md5"`
+ SHA256 string `json:"sha256"`
+ BuildDate int64 `json:"build_date"`
+ Packager string `json:"packager"`
+ Arch string `json:"arch"`
+ PgpSigned string `json:"pgp"`
+}
+
+// ParsePackage Function that receives arch package archive data and returns it's metadata.
+func ParsePackage(r *packages.HashedBuffer) (*Package, error) {
+ md5, _, sha256, _ := r.Sums()
+ _, err := r.Seek(0, io.SeekStart)
+ if err != nil {
+ return nil, err
+ }
+ header := make([]byte, 5)
+ _, err = r.Read(header)
+ if err != nil {
+ return nil, err
+ }
+ _, err = r.Seek(0, io.SeekStart)
+ if err != nil {
+ return nil, err
+ }
+
+ var tarball archiver.Reader
+ var tarballType string
+ if bytes.Equal(header[:len(magicZSTD)], magicZSTD) {
+ tarballType = "zst"
+ tarball = archiver.NewTarZstd()
+ } else if bytes.Equal(header[:len(magicXZ)], magicXZ) {
+ tarballType = "xz"
+ tarball = archiver.NewTarXz()
+ } else if bytes.Equal(header[:len(magicGZ)], magicGZ) {
+ tarballType = "gz"
+ tarball = archiver.NewTarGz()
+ } else {
+ return nil, errors.New("not supported compression")
+ }
+ err = tarball.Open(r, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer tarball.Close()
+
+ var pkg *Package
+ var mTree bool
+
+ for {
+ f, err := tarball.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ switch f.Name() {
+ case ".PKGINFO":
+ pkg, err = ParsePackageInfo(tarballType, f)
+ if err != nil {
+ _ = f.Close()
+ return nil, err
+ }
+ case ".MTREE":
+ mTree = true
+ }
+ _ = f.Close()
+ }
+
+ if pkg == nil {
+ return nil, util.NewInvalidArgumentErrorf(".PKGINFO file not found")
+ }
+
+ if !mTree {
+ return nil, util.NewInvalidArgumentErrorf(".MTREE file not found")
+ }
+
+ pkg.FileMetadata.CompressedSize = r.Size()
+ pkg.FileMetadata.MD5 = hex.EncodeToString(md5)
+ pkg.FileMetadata.SHA256 = hex.EncodeToString(sha256)
+
+ return pkg, nil
+}
+
+// ParsePackageInfo Function that accepts reader for .PKGINFO file from package archive,
+// validates all field according to PKGBUILD spec and returns package.
+func ParsePackageInfo(compressType string, r io.Reader) (*Package, error) {
+ p := &Package{
+ CompressType: compressType,
+ }
+
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ key, value, find := strings.Cut(line, "=")
+ if !find {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ value = strings.TrimSpace(value)
+ switch key {
+ case "pkgname":
+ p.Name = value
+ case "pkgbase":
+ p.VersionMetadata.Base = value
+ case "pkgver":
+ p.Version = value
+ case "pkgdesc":
+ p.VersionMetadata.Description = value
+ case "url":
+ p.VersionMetadata.ProjectURL = value
+ case "packager":
+ p.FileMetadata.Packager = value
+ case "arch":
+ p.FileMetadata.Arch = value
+ case "provides":
+ p.VersionMetadata.Provides = append(p.VersionMetadata.Provides, value)
+ case "license":
+ p.VersionMetadata.License = append(p.VersionMetadata.License, value)
+ case "depend":
+ p.VersionMetadata.Depends = append(p.VersionMetadata.Depends, value)
+ case "optdepend":
+ p.VersionMetadata.OptDepends = append(p.VersionMetadata.OptDepends, value)
+ case "makedepend":
+ p.VersionMetadata.MakeDepends = append(p.VersionMetadata.MakeDepends, value)
+ case "checkdepend":
+ p.VersionMetadata.CheckDepends = append(p.VersionMetadata.CheckDepends, value)
+ case "backup":
+ p.VersionMetadata.Backup = append(p.VersionMetadata.Backup, value)
+ case "group":
+ p.VersionMetadata.Groups = append(p.VersionMetadata.Groups, value)
+ case "conflict":
+ p.VersionMetadata.Conflicts = append(p.VersionMetadata.Conflicts, value)
+ case "replaces":
+ p.VersionMetadata.Replaces = append(p.VersionMetadata.Replaces, value)
+ case "xdata":
+ p.VersionMetadata.XData = append(p.VersionMetadata.XData, value)
+ case "builddate":
+ bd, err := strconv.ParseInt(value, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ p.FileMetadata.BuildDate = bd
+ case "size":
+ is, err := strconv.ParseInt(value, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ p.FileMetadata.InstalledSize = is
+ default:
+ return nil, util.NewInvalidArgumentErrorf("property is not supported %s", key)
+ }
+ }
+
+ return p, errors.Join(scanner.Err(), ValidatePackageSpec(p))
+}
+
+// ValidatePackageSpec Arch package validation according to PKGBUILD specification.
+func ValidatePackageSpec(p *Package) error {
+ if !reName.MatchString(p.Name) {
+ return util.NewInvalidArgumentErrorf("invalid package name")
+ }
+ if !reName.MatchString(p.VersionMetadata.Base) {
+ return util.NewInvalidArgumentErrorf("invalid package base")
+ }
+ if !reVer.MatchString(p.Version) {
+ return util.NewInvalidArgumentErrorf("invalid package version")
+ }
+ if p.FileMetadata.Arch == "" {
+ return util.NewInvalidArgumentErrorf("architecture should be specified")
+ }
+ if p.VersionMetadata.ProjectURL != "" {
+ if !validation.IsValidURL(p.VersionMetadata.ProjectURL) {
+ return util.NewInvalidArgumentErrorf("invalid project URL")
+ }
+ }
+ for _, checkDepend := range p.VersionMetadata.CheckDepends {
+ if !rePkgVer.MatchString(checkDepend) {
+ return util.NewInvalidArgumentErrorf("invalid check dependency: %s", checkDepend)
+ }
+ }
+ for _, depend := range p.VersionMetadata.Depends {
+ if !rePkgVer.MatchString(depend) {
+ return util.NewInvalidArgumentErrorf("invalid dependency: %s", depend)
+ }
+ }
+ for _, makeDepend := range p.VersionMetadata.MakeDepends {
+ if !rePkgVer.MatchString(makeDepend) {
+ return util.NewInvalidArgumentErrorf("invalid make dependency: %s", makeDepend)
+ }
+ }
+ for _, provide := range p.VersionMetadata.Provides {
+ if !rePkgVer.MatchString(provide) {
+ return util.NewInvalidArgumentErrorf("invalid provides: %s", provide)
+ }
+ }
+ for _, conflict := range p.VersionMetadata.Conflicts {
+ if !rePkgVer.MatchString(conflict) {
+ return util.NewInvalidArgumentErrorf("invalid conflicts: %s", conflict)
+ }
+ }
+ for _, replace := range p.VersionMetadata.Replaces {
+ if !rePkgVer.MatchString(replace) {
+ return util.NewInvalidArgumentErrorf("invalid replaces: %s", replace)
+ }
+ }
+ for _, optDepend := range p.VersionMetadata.OptDepends {
+ if !reOptDep.MatchString(optDepend) {
+ return util.NewInvalidArgumentErrorf("invalid optional dependency: %s", optDepend)
+ }
+ }
+ for _, b := range p.VersionMetadata.Backup {
+ if strings.HasPrefix(b, "/") {
+ return util.NewInvalidArgumentErrorf("backup file contains leading forward slash")
+ }
+ }
+ return nil
+}
+
+// Desc Create pacman package description file.
+func (p *Package) Desc() string {
+ entries := []string{
+ "FILENAME", fmt.Sprintf("%s-%s-%s.pkg.tar.%s", p.Name, p.Version, p.FileMetadata.Arch, p.CompressType),
+ "NAME", p.Name,
+ "BASE", p.VersionMetadata.Base,
+ "VERSION", p.Version,
+ "DESC", p.VersionMetadata.Description,
+ "GROUPS", strings.Join(p.VersionMetadata.Groups, "\n"),
+ "CSIZE", fmt.Sprintf("%d", p.FileMetadata.CompressedSize),
+ "ISIZE", fmt.Sprintf("%d", p.FileMetadata.InstalledSize),
+ "MD5SUM", p.FileMetadata.MD5,
+ "SHA256SUM", p.FileMetadata.SHA256,
+ "PGPSIG", p.FileMetadata.PgpSigned,
+ "URL", p.VersionMetadata.ProjectURL,
+ "LICENSE", strings.Join(p.VersionMetadata.License, "\n"),
+ "ARCH", p.FileMetadata.Arch,
+ "BUILDDATE", fmt.Sprintf("%d", p.FileMetadata.BuildDate),
+ "PACKAGER", p.FileMetadata.Packager,
+ "REPLACES", strings.Join(p.VersionMetadata.Replaces, "\n"),
+ "CONFLICTS", strings.Join(p.VersionMetadata.Conflicts, "\n"),
+ "PROVIDES", strings.Join(p.VersionMetadata.Provides, "\n"),
+ "DEPENDS", strings.Join(p.VersionMetadata.Depends, "\n"),
+ "OPTDEPENDS", strings.Join(p.VersionMetadata.OptDepends, "\n"),
+ "MAKEDEPENDS", strings.Join(p.VersionMetadata.MakeDepends, "\n"),
+ "CHECKDEPENDS", strings.Join(p.VersionMetadata.CheckDepends, "\n"),
+ }
+
+ var buf bytes.Buffer
+ for i := 0; i < len(entries); i += 2 {
+ if entries[i+1] != "" {
+ _, _ = fmt.Fprintf(&buf, "%%%s%%\n%s\n\n", entries[i], entries[i+1])
+ }
+ }
+ return buf.String()
+}
diff --git a/modules/packages/arch/metadata_test.go b/modules/packages/arch/metadata_test.go
new file mode 100644
index 0000000..ddb35ca
--- /dev/null
+++ b/modules/packages/arch/metadata_test.go
@@ -0,0 +1,447 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "code.gitea.io/gitea/modules/packages"
+
+ "github.com/mholt/archiver/v3"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePackage(t *testing.T) {
+ // Minimal PKGINFO contents and test FS
+ const PKGINFO = `pkgname = a
+pkgbase = b
+pkgver = 1-2
+arch = x86_64
+`
+ fs := fstest.MapFS{
+ "pkginfo": &fstest.MapFile{
+ Data: []byte(PKGINFO),
+ Mode: os.ModePerm,
+ ModTime: time.Now(),
+ },
+ "mtree": &fstest.MapFile{
+ Data: []byte("data"),
+ Mode: os.ModePerm,
+ ModTime: time.Now(),
+ },
+ }
+
+ // Test .PKGINFO file
+ pinf, err := fs.Stat("pkginfo")
+ require.NoError(t, err)
+
+ pfile, err := fs.Open("pkginfo")
+ require.NoError(t, err)
+
+ parcname, err := archiver.NameInArchive(pinf, ".PKGINFO", ".PKGINFO")
+ require.NoError(t, err)
+
+ // Test .MTREE file
+ minf, err := fs.Stat("mtree")
+ require.NoError(t, err)
+
+ mfile, err := fs.Open("mtree")
+ require.NoError(t, err)
+
+ marcname, err := archiver.NameInArchive(minf, ".MTREE", ".MTREE")
+ require.NoError(t, err)
+
+ t.Run("normal archive", func(t *testing.T) {
+ var buf bytes.Buffer
+
+ archive := archiver.NewTarZstd()
+ archive.Create(&buf)
+
+ err = archive.Write(archiver.File{
+ FileInfo: archiver.FileInfo{
+ FileInfo: pinf,
+ CustomName: parcname,
+ },
+ ReadCloser: pfile,
+ })
+ require.NoError(t, errors.Join(pfile.Close(), err))
+
+ err = archive.Write(archiver.File{
+ FileInfo: archiver.FileInfo{
+ FileInfo: minf,
+ CustomName: marcname,
+ },
+ ReadCloser: mfile,
+ })
+ require.NoError(t, errors.Join(mfile.Close(), archive.Close(), err))
+
+ reader, err := packages.CreateHashedBufferFromReader(&buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ _, err = ParsePackage(reader)
+
+ require.NoError(t, err)
+ })
+
+ t.Run("missing .PKGINFO", func(t *testing.T) {
+ var buf bytes.Buffer
+
+ archive := archiver.NewTarZstd()
+ archive.Create(&buf)
+ require.NoError(t, archive.Close())
+
+ reader, err := packages.CreateHashedBufferFromReader(&buf)
+ require.NoError(t, err)
+
+ defer reader.Close()
+ _, err = ParsePackage(reader)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), ".PKGINFO file not found")
+ })
+
+ t.Run("missing .MTREE", func(t *testing.T) {
+ var buf bytes.Buffer
+
+ pfile, err := fs.Open("pkginfo")
+ require.NoError(t, err)
+
+ archive := archiver.NewTarZstd()
+ archive.Create(&buf)
+
+ err = archive.Write(archiver.File{
+ FileInfo: archiver.FileInfo{
+ FileInfo: pinf,
+ CustomName: parcname,
+ },
+ ReadCloser: pfile,
+ })
+ require.NoError(t, errors.Join(pfile.Close(), archive.Close(), err))
+ reader, err := packages.CreateHashedBufferFromReader(&buf)
+ require.NoError(t, err)
+
+ defer reader.Close()
+ _, err = ParsePackage(reader)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), ".MTREE file not found")
+ })
+}
+
+func TestParsePackageInfo(t *testing.T) {
+ const PKGINFO = `# Generated by makepkg 6.0.2
+# using fakeroot version 1.31
+pkgname = a
+pkgbase = b
+pkgver = 1-2
+pkgdesc = comment
+url = https://example.com/
+group = group
+builddate = 3
+packager = Name Surname <login@example.com>
+size = 5
+arch = x86_64
+license = BSD
+provides = pvd
+depend = smth
+optdepend = hex
+checkdepend = ola
+makedepend = cmake
+backup = usr/bin/paket1
+`
+ p, err := ParsePackageInfo("zst", strings.NewReader(PKGINFO))
+ require.NoError(t, err)
+ require.Equal(t, Package{
+ CompressType: "zst",
+ Name: "a",
+ Version: "1-2",
+ VersionMetadata: VersionMetadata{
+ Base: "b",
+ Description: "comment",
+ ProjectURL: "https://example.com/",
+ Groups: []string{"group"},
+ Provides: []string{"pvd"},
+ License: []string{"BSD"},
+ Depends: []string{"smth"},
+ OptDepends: []string{"hex"},
+ MakeDepends: []string{"cmake"},
+ CheckDepends: []string{"ola"},
+ Backup: []string{"usr/bin/paket1"},
+ },
+ FileMetadata: FileMetadata{
+ InstalledSize: 5,
+ BuildDate: 3,
+ Packager: "Name Surname <login@example.com>",
+ Arch: "x86_64",
+ },
+ }, *p)
+}
+
+func TestValidatePackageSpec(t *testing.T) {
+ newpkg := func() Package {
+ return Package{
+ Name: "abc",
+ Version: "1-1",
+ VersionMetadata: VersionMetadata{
+ Base: "ghx",
+ Description: "whoami",
+ ProjectURL: "https://example.com/",
+ Groups: []string{"gnome"},
+ Provides: []string{"abc", "def"},
+ License: []string{"GPL"},
+ Depends: []string{"go", "gpg=1", "curl>=3", "git<=7"},
+ OptDepends: []string{"git", "libgcc=1.0", "gzip>1.0", "gz>=1.0", "lz<1.0", "gzip<=1.0", "zstd>1.0:foo bar<test>"},
+ MakeDepends: []string{"chrom"},
+ CheckDepends: []string{"bariy"},
+ Backup: []string{"etc/pacman.d/filo"},
+ },
+ FileMetadata: FileMetadata{
+ CompressedSize: 1,
+ InstalledSize: 2,
+ SHA256: "def",
+ BuildDate: 3,
+ Packager: "smon",
+ Arch: "x86_64",
+ },
+ }
+ }
+
+ t.Run("valid package", func(t *testing.T) {
+ p := newpkg()
+
+ err := ValidatePackageSpec(&p)
+
+ require.NoError(t, err)
+ })
+
+ t.Run("invalid package name", func(t *testing.T) {
+ p := newpkg()
+ p.Name = "!$%@^!*&()"
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid package name")
+ })
+
+ t.Run("invalid package base", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.Base = "!$%@^!*&()"
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid package base")
+ })
+
+ t.Run("invalid package version", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.Base = "una-luna?"
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid package base")
+ })
+
+ t.Run("invalid package version", func(t *testing.T) {
+ p := newpkg()
+ p.Version = "una-luna"
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid package version")
+ })
+
+ t.Run("missing architecture", func(t *testing.T) {
+ p := newpkg()
+ p.FileMetadata.Arch = ""
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "architecture should be specified")
+ })
+
+ t.Run("invalid URL", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.ProjectURL = "http%%$#"
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid project URL")
+ })
+
+ t.Run("invalid check dependency", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.CheckDepends = []string{"Err^_^"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid check dependency")
+ })
+
+ t.Run("invalid dependency", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.Depends = []string{"^^abc"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid dependency")
+ })
+
+ t.Run("invalid make dependency", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.MakeDepends = []string{"^m^"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid make dependency")
+ })
+
+ t.Run("invalid provides", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.Provides = []string{"^m^"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid provides")
+ })
+
+ t.Run("invalid optional dependency", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.OptDepends = []string{"^m^:MM"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid optional dependency")
+ })
+
+ t.Run("invalid optional dependency", func(t *testing.T) {
+ p := newpkg()
+ p.VersionMetadata.Backup = []string{"/ola/cola"}
+
+ err := ValidatePackageSpec(&p)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "backup file contains leading forward slash")
+ })
+}
+
+func TestDescString(t *testing.T) {
+ const pkgdesc = `%FILENAME%
+zstd-1.5.5-1-x86_64.pkg.tar.zst
+
+%NAME%
+zstd
+
+%BASE%
+zstd
+
+%VERSION%
+1.5.5-1
+
+%DESC%
+Zstandard - Fast real-time compression algorithm
+
+%GROUPS%
+dummy1
+dummy2
+
+%CSIZE%
+401
+
+%ISIZE%
+1500453
+
+%MD5SUM%
+5016660ef3d9aa148a7b72a08d3df1b2
+
+%SHA256SUM%
+9fa4ede47e35f5971e4f26ecadcbfb66ab79f1d638317ac80334a3362dedbabd
+
+%URL%
+https://facebook.github.io/zstd/
+
+%LICENSE%
+BSD
+GPL2
+
+%ARCH%
+x86_64
+
+%BUILDDATE%
+1681646714
+
+%PACKAGER%
+Jelle van der Waa <jelle@archlinux.org>
+
+%PROVIDES%
+libzstd.so=1-64
+
+%DEPENDS%
+glibc
+gcc-libs
+zlib
+xz
+lz4
+
+%OPTDEPENDS%
+dummy3
+dummy4
+
+%MAKEDEPENDS%
+cmake
+gtest
+ninja
+
+%CHECKDEPENDS%
+dummy5
+dummy6
+
+`
+
+ md := &Package{
+ CompressType: "zst",
+ Name: "zstd",
+ Version: "1.5.5-1",
+ VersionMetadata: VersionMetadata{
+ Base: "zstd",
+ Description: "Zstandard - Fast real-time compression algorithm",
+ ProjectURL: "https://facebook.github.io/zstd/",
+ Groups: []string{"dummy1", "dummy2"},
+ Provides: []string{"libzstd.so=1-64"},
+ License: []string{"BSD", "GPL2"},
+ Depends: []string{"glibc", "gcc-libs", "zlib", "xz", "lz4"},
+ OptDepends: []string{"dummy3", "dummy4"},
+ MakeDepends: []string{"cmake", "gtest", "ninja"},
+ CheckDepends: []string{"dummy5", "dummy6"},
+ },
+ FileMetadata: FileMetadata{
+ CompressedSize: 401,
+ InstalledSize: 1500453,
+ MD5: "5016660ef3d9aa148a7b72a08d3df1b2",
+ SHA256: "9fa4ede47e35f5971e4f26ecadcbfb66ab79f1d638317ac80334a3362dedbabd",
+ BuildDate: 1681646714,
+ Packager: "Jelle van der Waa <jelle@archlinux.org>",
+ Arch: "x86_64",
+ },
+ }
+ require.Equal(t, pkgdesc, md.Desc())
+}
diff --git a/modules/packages/cargo/parser.go b/modules/packages/cargo/parser.go
new file mode 100644
index 0000000..36cd44d
--- /dev/null
+++ b/modules/packages/cargo/parser.go
@@ -0,0 +1,169 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cargo
+
+import (
+ "encoding/binary"
+ "errors"
+ "io"
+ "regexp"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+)
+
+const PropertyYanked = "cargo.yanked"
+
+var (
+ ErrInvalidName = errors.New("package name is invalid")
+ ErrInvalidVersion = errors.New("package version is invalid")
+)
+
+// Package represents a Cargo package
+type Package struct {
+ Name string
+ Version string
+ Metadata *Metadata
+ Content io.Reader
+ ContentSize int64
+}
+
+// Metadata represents the metadata of a Cargo package
+type Metadata struct {
+ Dependencies []*Dependency `json:"dependencies,omitempty"`
+ Features map[string][]string `json:"features,omitempty"`
+ Authors []string `json:"authors,omitempty"`
+ Description string `json:"description,omitempty"`
+ DocumentationURL string `json:"documentation_url,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Categories []string `json:"categories,omitempty"`
+ License string `json:"license,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ Links string `json:"links,omitempty"`
+}
+
+type Dependency struct {
+ Name string `json:"name"`
+ Req string `json:"req"`
+ Features []string `json:"features"`
+ Optional bool `json:"optional"`
+ DefaultFeatures bool `json:"default_features"`
+ Target *string `json:"target"`
+ Kind string `json:"kind"`
+ Registry *string `json:"registry"`
+ Package *string `json:"package"`
+}
+
+var nameMatch = regexp.MustCompile(`\A[a-zA-Z][a-zA-Z0-9-_]{0,63}\z`)
+
+// ParsePackage reads the metadata and content of a package
+func ParsePackage(r io.Reader) (*Package, error) {
+ var size uint32
+ if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
+ return nil, err
+ }
+
+ p, err := parsePackage(io.LimitReader(r, int64(size)))
+ if err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
+ return nil, err
+ }
+
+ p.Content = io.LimitReader(r, int64(size))
+ p.ContentSize = int64(size)
+
+ return p, nil
+}
+
+func parsePackage(r io.Reader) (*Package, error) {
+ var meta struct {
+ Name string `json:"name"`
+ Vers string `json:"vers"`
+ Deps []struct {
+ Name string `json:"name"`
+ VersionReq string `json:"version_req"`
+ Features []string `json:"features"`
+ Optional bool `json:"optional"`
+ DefaultFeatures bool `json:"default_features"`
+ Target *string `json:"target"`
+ Kind string `json:"kind"`
+ Registry *string `json:"registry"`
+ ExplicitNameInToml string `json:"explicit_name_in_toml"`
+ } `json:"deps"`
+ Features map[string][]string `json:"features"`
+ Authors []string `json:"authors"`
+ Description string `json:"description"`
+ Documentation string `json:"documentation"`
+ Homepage string `json:"homepage"`
+ Readme string `json:"readme"`
+ ReadmeFile string `json:"readme_file"`
+ Keywords []string `json:"keywords"`
+ Categories []string `json:"categories"`
+ License string `json:"license"`
+ LicenseFile string `json:"license_file"`
+ Repository string `json:"repository"`
+ Links string `json:"links"`
+ }
+ if err := json.NewDecoder(r).Decode(&meta); err != nil {
+ return nil, err
+ }
+
+ if !nameMatch.MatchString(meta.Name) {
+ return nil, ErrInvalidName
+ }
+
+ if _, err := version.NewSemver(meta.Vers); err != nil {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(meta.Homepage) {
+ meta.Homepage = ""
+ }
+ if !validation.IsValidURL(meta.Documentation) {
+ meta.Documentation = ""
+ }
+ if !validation.IsValidURL(meta.Repository) {
+ meta.Repository = ""
+ }
+
+ dependencies := make([]*Dependency, 0, len(meta.Deps))
+ for _, dep := range meta.Deps {
+ dependencies = append(dependencies, &Dependency{
+ Name: dep.Name,
+ Req: dep.VersionReq,
+ Features: dep.Features,
+ Optional: dep.Optional,
+ DefaultFeatures: dep.DefaultFeatures,
+ Target: dep.Target,
+ Kind: dep.Kind,
+ Registry: dep.Registry,
+ })
+ }
+
+ return &Package{
+ Name: meta.Name,
+ Version: meta.Vers,
+ Metadata: &Metadata{
+ Dependencies: dependencies,
+ Features: meta.Features,
+ Authors: meta.Authors,
+ Description: meta.Description,
+ DocumentationURL: meta.Documentation,
+ ProjectURL: meta.Homepage,
+ Readme: meta.Readme,
+ Keywords: meta.Keywords,
+ Categories: meta.Categories,
+ License: meta.License,
+ RepositoryURL: meta.Repository,
+ Links: meta.Links,
+ },
+ }, nil
+}
diff --git a/modules/packages/cargo/parser_test.go b/modules/packages/cargo/parser_test.go
new file mode 100644
index 0000000..4b357cb
--- /dev/null
+++ b/modules/packages/cargo/parser_test.go
@@ -0,0 +1,87 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cargo
+
+import (
+ "bytes"
+ "encoding/binary"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ description = "Package Description"
+ author = "KN4CK3R"
+ homepage = "https://gitea.io/"
+ license = "MIT"
+)
+
+func TestParsePackage(t *testing.T) {
+ createPackage := func(name, version string) io.Reader {
+ metadata := `{
+ "name":"` + name + `",
+ "vers":"` + version + `",
+ "description":"` + description + `",
+ "authors": ["` + author + `"],
+ "deps":[
+ {
+ "name":"dep",
+ "version_req":"1.0"
+ }
+ ],
+ "homepage":"` + homepage + `",
+ "license":"` + license + `"
+}`
+
+ var buf bytes.Buffer
+ binary.Write(&buf, binary.LittleEndian, uint32(len(metadata)))
+ buf.WriteString(metadata)
+ binary.Write(&buf, binary.LittleEndian, uint32(4))
+ buf.WriteString("test")
+ return &buf
+ }
+
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{"", "0test", "-test", "_test", strings.Repeat("a", 65)} {
+ data := createPackage(name, "1.0.0")
+
+ cp, err := ParsePackage(data)
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ for _, version := range []string{"", "1.", "-1.0", "1.0.0/1"} {
+ data := createPackage("test", version)
+
+ cp, err := ParsePackage(data)
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ }
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createPackage("test", "1.0.0")
+
+ cp, err := ParsePackage(data)
+ assert.NotNil(t, cp)
+ require.NoError(t, err)
+
+ assert.Equal(t, "test", cp.Name)
+ assert.Equal(t, "1.0.0", cp.Version)
+ assert.Equal(t, description, cp.Metadata.Description)
+ assert.Equal(t, []string{author}, cp.Metadata.Authors)
+ assert.Len(t, cp.Metadata.Dependencies, 1)
+ assert.Equal(t, "dep", cp.Metadata.Dependencies[0].Name)
+ assert.Equal(t, homepage, cp.Metadata.ProjectURL)
+ assert.Equal(t, license, cp.Metadata.License)
+ content, _ := io.ReadAll(cp.Content)
+ assert.Equal(t, "test", string(content))
+ })
+}
diff --git a/modules/packages/chef/metadata.go b/modules/packages/chef/metadata.go
new file mode 100644
index 0000000..a1c9187
--- /dev/null
+++ b/modules/packages/chef/metadata.go
@@ -0,0 +1,134 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package chef
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+)
+
+const (
+ KeyBits = 4096
+ SettingPublicPem = "chef.public_pem"
+)
+
+var (
+ ErrMissingMetadataFile = util.NewInvalidArgumentErrorf("metadata.json file is missing")
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+
+ namePattern = regexp.MustCompile(`\A\S+\z`)
+ versionPattern = regexp.MustCompile(`\A\d+\.\d+(?:\.\d+)?\z`)
+)
+
+// Package represents a Chef package
+type Package struct {
+ Name string
+ Version string
+ Metadata *Metadata
+}
+
+// Metadata represents the metadata of a Chef package
+type Metadata struct {
+ Description string `json:"description,omitempty"`
+ LongDescription string `json:"long_description,omitempty"`
+ Author string `json:"author,omitempty"`
+ License string `json:"license,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ Dependencies map[string]string `json:"dependencies,omitempty"`
+}
+
+type chefMetadata struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ LongDescription string `json:"long_description"`
+ Maintainer string `json:"maintainer"`
+ MaintainerEmail string `json:"maintainer_email"`
+ License string `json:"license"`
+ Platforms map[string]string `json:"platforms"`
+ Dependencies map[string]string `json:"dependencies"`
+ Providing map[string]string `json:"providing"`
+ Recipes map[string]string `json:"recipes"`
+ Version string `json:"version"`
+ SourceURL string `json:"source_url"`
+ IssuesURL string `json:"issues_url"`
+ Privacy bool `json:"privacy"`
+ ChefVersions [][]string `json:"chef_versions"`
+ Gems [][]string `json:"gems"`
+ EagerLoadLibraries bool `json:"eager_load_libraries"`
+}
+
+// ParsePackage parses the Chef package file
+func ParsePackage(r io.Reader) (*Package, error) {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if strings.Count(hd.Name, "/") != 1 {
+ continue
+ }
+
+ if hd.FileInfo().Name() == "metadata.json" {
+ return ParseChefMetadata(tr)
+ }
+ }
+
+ return nil, ErrMissingMetadataFile
+}
+
+// ParseChefMetadata parses a metadata.json file to retrieve the metadata of a Chef package
+func ParseChefMetadata(r io.Reader) (*Package, error) {
+ var cm chefMetadata
+ if err := json.NewDecoder(r).Decode(&cm); err != nil {
+ return nil, err
+ }
+
+ if !namePattern.MatchString(cm.Name) {
+ return nil, ErrInvalidName
+ }
+
+ if !versionPattern.MatchString(cm.Version) {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(cm.SourceURL) {
+ cm.SourceURL = ""
+ }
+
+ return &Package{
+ Name: cm.Name,
+ Version: cm.Version,
+ Metadata: &Metadata{
+ Description: cm.Description,
+ LongDescription: cm.LongDescription,
+ Author: cm.Maintainer,
+ License: cm.License,
+ RepositoryURL: cm.SourceURL,
+ Dependencies: cm.Dependencies,
+ },
+ }, nil
+}
diff --git a/modules/packages/chef/metadata_test.go b/modules/packages/chef/metadata_test.go
new file mode 100644
index 0000000..8784c62
--- /dev/null
+++ b/modules/packages/chef/metadata_test.go
@@ -0,0 +1,93 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package chef
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ packageAuthor = "KN4CK3R"
+ packageDescription = "Package Description"
+ packageRepositoryURL = "https://gitea.io/gitea/gitea"
+)
+
+func TestParsePackage(t *testing.T) {
+ t.Run("MissingMetadataFile", func(t *testing.T) {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(zw)
+ tw.Close()
+ zw.Close()
+
+ p, err := ParsePackage(&buf)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrMissingMetadataFile)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(zw)
+
+ content := `{"name":"` + packageName + `","version":"` + packageVersion + `"}`
+
+ hdr := &tar.Header{
+ Name: packageName + "/metadata.json",
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write([]byte(content))
+
+ tw.Close()
+ zw.Close()
+
+ p, err := ParsePackage(&buf)
+ require.NoError(t, err)
+ assert.NotNil(t, p)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.NotNil(t, p.Metadata)
+ })
+}
+
+func TestParseChefMetadata(t *testing.T) {
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{" test", "test "} {
+ p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + name + `","version":"1.0.0"}`))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ for _, version := range []string{"1", "1.2.3.4", "1.0.0 "} {
+ p, err := ParseChefMetadata(strings.NewReader(`{"name":"test","version":"` + version + `"}`))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ }
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + packageName + `","version":"` + packageVersion + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `","source_url":"` + packageRepositoryURL + `"}`))
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, packageDescription, p.Metadata.Description)
+ assert.Equal(t, packageAuthor, p.Metadata.Author)
+ assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL)
+ })
+}
diff --git a/modules/packages/composer/metadata.go b/modules/packages/composer/metadata.go
new file mode 100644
index 0000000..6035eae
--- /dev/null
+++ b/modules/packages/composer/metadata.go
@@ -0,0 +1,187 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package composer
+
+import (
+ "archive/zip"
+ "io"
+ "path"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+)
+
+// TypeProperty is the name of the property for Composer package types
+const TypeProperty = "composer.type"
+
+var (
+ // ErrMissingComposerFile indicates a missing composer.json file
+ ErrMissingComposerFile = util.NewInvalidArgumentErrorf("composer.json file is missing")
+ // ErrInvalidName indicates an invalid package name
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ // ErrInvalidVersion indicates an invalid package version
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+)
+
+// Package represents a Composer package
+type Package struct {
+ Name string
+ Version string
+ Type string
+ Metadata *Metadata
+}
+
+// https://getcomposer.org/doc/04-schema.md
+
+// Metadata represents the metadata of a Composer package
+type Metadata struct {
+ Description string `json:"description,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Comments Comments `json:"_comments,omitempty"`
+ Homepage string `json:"homepage,omitempty"`
+ License Licenses `json:"license,omitempty"`
+ Authors []Author `json:"authors,omitempty"`
+ Bin []string `json:"bin,omitempty"`
+ Autoload map[string]any `json:"autoload,omitempty"`
+ AutoloadDev map[string]any `json:"autoload-dev,omitempty"`
+ Extra map[string]any `json:"extra,omitempty"`
+ Require map[string]string `json:"require,omitempty"`
+ RequireDev map[string]string `json:"require-dev,omitempty"`
+ Suggest map[string]string `json:"suggest,omitempty"`
+ Provide map[string]string `json:"provide,omitempty"`
+}
+
+// Licenses represents the licenses of a Composer package
+type Licenses []string
+
+// UnmarshalJSON reads from a string or array
+func (l *Licenses) UnmarshalJSON(data []byte) error {
+ switch data[0] {
+ case '"':
+ var value string
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ *l = Licenses{value}
+ case '[':
+ values := make([]string, 0, 5)
+ if err := json.Unmarshal(data, &values); err != nil {
+ return err
+ }
+ *l = Licenses(values)
+ }
+ return nil
+}
+
+// Comments represents the comments of a Composer package
+type Comments []string
+
+// UnmarshalJSON reads from a string or array
+func (c *Comments) UnmarshalJSON(data []byte) error {
+ switch data[0] {
+ case '"':
+ var value string
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ *c = Comments{value}
+ case '[':
+ values := make([]string, 0, 5)
+ if err := json.Unmarshal(data, &values); err != nil {
+ return err
+ }
+ *c = Comments(values)
+ }
+ return nil
+}
+
+// Author represents an author
+type Author struct {
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Homepage string `json:"homepage,omitempty"`
+}
+
+var nameMatch = regexp.MustCompile(`\A[a-z0-9]([_\.-]?[a-z0-9]+)*/[a-z0-9](([_\.]?|-{0,2})[a-z0-9]+)*\z`)
+
+// ParsePackage parses the metadata of a Composer package file
+func ParsePackage(r io.ReaderAt, size int64) (*Package, error) {
+ archive, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, file := range archive.File {
+ if strings.Count(file.Name, "/") > 1 {
+ continue
+ }
+ if strings.HasSuffix(strings.ToLower(file.Name), "composer.json") {
+ f, err := archive.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ return ParseComposerFile(archive, path.Dir(file.Name), f)
+ }
+ }
+ return nil, ErrMissingComposerFile
+}
+
+// ParseComposerFile parses a composer.json file to retrieve the metadata of a Composer package
+func ParseComposerFile(archive *zip.Reader, pathPrefix string, r io.Reader) (*Package, error) {
+ var cj struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Type string `json:"type"`
+ Metadata
+ }
+ if err := json.NewDecoder(r).Decode(&cj); err != nil {
+ return nil, err
+ }
+
+ if !nameMatch.MatchString(cj.Name) {
+ return nil, ErrInvalidName
+ }
+
+ if cj.Version != "" {
+ if _, err := version.NewSemver(cj.Version); err != nil {
+ return nil, ErrInvalidVersion
+ }
+ }
+
+ if !validation.IsValidURL(cj.Homepage) {
+ cj.Homepage = ""
+ }
+
+ if cj.Type == "" {
+ cj.Type = "library"
+ }
+
+ if cj.Readme == "" {
+ cj.Readme = "README.md"
+ }
+ f, err := archive.Open(path.Join(pathPrefix, cj.Readme))
+ if err == nil {
+ // 10kb limit for readme content
+ buf, _ := io.ReadAll(io.LimitReader(f, 10*1024))
+ cj.Readme = string(buf)
+ _ = f.Close()
+ } else {
+ cj.Readme = ""
+ }
+
+ return &Package{
+ Name: cj.Name,
+ Version: cj.Version,
+ Type: cj.Type,
+ Metadata: &cj.Metadata,
+ }, nil
+}
diff --git a/modules/packages/composer/metadata_test.go b/modules/packages/composer/metadata_test.go
new file mode 100644
index 0000000..2bdb239
--- /dev/null
+++ b/modules/packages/composer/metadata_test.go
@@ -0,0 +1,154 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package composer
+
+import (
+ "archive/zip"
+ "bytes"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ name = "gitea/composer-package"
+ description = "Package Description"
+ readme = "Package Readme"
+ comments = "Package Comment"
+ packageType = "composer-plugin"
+ author = "Gitea Authors"
+ email = "no.reply@gitea.io"
+ homepage = "https://gitea.io"
+ license = "MIT"
+)
+
+const composerContent = `{
+ "name": "` + name + `",
+ "description": "` + description + `",
+ "type": "` + packageType + `",
+ "license": "` + license + `",
+ "authors": [
+ {
+ "name": "` + author + `",
+ "email": "` + email + `"
+ }
+ ],
+ "homepage": "` + homepage + `",
+ "autoload": {
+ "psr-4": {"Gitea\\ComposerPackage\\": "src/"}
+ },
+ "require": {
+ "php": ">=7.2 || ^8.0"
+ },
+ "_comments": "` + comments + `"
+}`
+
+func TestLicenseUnmarshal(t *testing.T) {
+ var l Licenses
+ require.NoError(t, json.NewDecoder(strings.NewReader(`["MIT"]`)).Decode(&l))
+ assert.Len(t, l, 1)
+ assert.Equal(t, "MIT", l[0])
+ require.NoError(t, json.NewDecoder(strings.NewReader(`"MIT"`)).Decode(&l))
+ assert.Len(t, l, 1)
+ assert.Equal(t, "MIT", l[0])
+}
+
+func TestCommentsUnmarshal(t *testing.T) {
+ var c Comments
+ require.NoError(t, json.NewDecoder(strings.NewReader(`["comment"]`)).Decode(&c))
+ assert.Len(t, c, 1)
+ assert.Equal(t, "comment", c[0])
+ require.NoError(t, json.NewDecoder(strings.NewReader(`"comment"`)).Decode(&c))
+ assert.Len(t, c, 1)
+ assert.Equal(t, "comment", c[0])
+}
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string]string) []byte {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, _ := archive.Create(name)
+ w.Write([]byte(content))
+ }
+ archive.Close()
+ return buf.Bytes()
+ }
+
+ t.Run("MissingComposerFile", func(t *testing.T) {
+ data := createArchive(map[string]string{"dummy.txt": ""})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrMissingComposerFile)
+ })
+
+ t.Run("MissingComposerFileInRoot", func(t *testing.T) {
+ data := createArchive(map[string]string{"sub/sub/composer.json": ""})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrMissingComposerFile)
+ })
+
+ t.Run("InvalidComposerFile", func(t *testing.T) {
+ data := createArchive(map[string]string{"composer.json": ""})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, cp)
+ require.Error(t, err)
+ })
+
+ t.Run("InvalidPackageName", func(t *testing.T) {
+ data := createArchive(map[string]string{"composer.json": "{}"})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrInvalidName)
+ })
+
+ t.Run("InvalidPackageVersion", func(t *testing.T) {
+ data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "version": "1.a.3"}`})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, cp)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ })
+
+ t.Run("InvalidReadmePath", func(t *testing.T) {
+ data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "readme": "sub/README.md"}`})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, cp)
+
+ assert.Empty(t, cp.Metadata.Readme)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createArchive(map[string]string{"composer.json": composerContent, "README.md": readme})
+
+ cp, err := ParsePackage(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, cp)
+
+ assert.Equal(t, name, cp.Name)
+ assert.Empty(t, cp.Version)
+ assert.Equal(t, description, cp.Metadata.Description)
+ assert.Equal(t, readme, cp.Metadata.Readme)
+ assert.Len(t, cp.Metadata.Comments, 1)
+ assert.Equal(t, comments, cp.Metadata.Comments[0])
+ assert.Len(t, cp.Metadata.Authors, 1)
+ assert.Equal(t, author, cp.Metadata.Authors[0].Name)
+ assert.Equal(t, email, cp.Metadata.Authors[0].Email)
+ assert.Equal(t, homepage, cp.Metadata.Homepage)
+ assert.Equal(t, packageType, cp.Type)
+ assert.Len(t, cp.Metadata.License, 1)
+ assert.Equal(t, license, cp.Metadata.License[0])
+ })
+}
diff --git a/modules/packages/conan/conanfile_parser.go b/modules/packages/conan/conanfile_parser.go
new file mode 100644
index 0000000..c47b242
--- /dev/null
+++ b/modules/packages/conan/conanfile_parser.go
@@ -0,0 +1,67 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "io"
+ "regexp"
+ "strings"
+)
+
+var (
+ patternAuthor = compilePattern("author")
+ patternHomepage = compilePattern("homepage")
+ patternURL = compilePattern("url")
+ patternLicense = compilePattern("license")
+ patternDescription = compilePattern("description")
+ patternTopics = regexp.MustCompile(`(?im)^\s*topics\s*=\s*\((.+)\)`)
+ patternTopicList = regexp.MustCompile(`\s*['"](.+?)['"]\s*,?`)
+)
+
+func compilePattern(name string) *regexp.Regexp {
+ return regexp.MustCompile(`(?im)^\s*` + name + `\s*=\s*['"\(](.+)['"\)]`)
+}
+
+func ParseConanfile(r io.Reader) (*Metadata, error) {
+ buf, err := io.ReadAll(io.LimitReader(r, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+
+ metadata := &Metadata{}
+
+ m := patternAuthor.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ metadata.Author = string(m[1])
+ }
+ m = patternHomepage.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ metadata.ProjectURL = string(m[1])
+ }
+ m = patternURL.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ metadata.RepositoryURL = string(m[1])
+ }
+ m = patternLicense.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ metadata.License = strings.ReplaceAll(strings.ReplaceAll(string(m[1]), "'", ""), "\"", "")
+ }
+ m = patternDescription.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ metadata.Description = string(m[1])
+ }
+ m = patternTopics.FindSubmatch(buf)
+ if len(m) > 1 && len(m[1]) > 0 {
+ m2 := patternTopicList.FindAllSubmatch(m[1], -1)
+ if len(m2) > 0 {
+ metadata.Keywords = make([]string, 0, len(m2))
+ for _, g := range m2 {
+ if len(g) > 1 {
+ metadata.Keywords = append(metadata.Keywords, string(g[1]))
+ }
+ }
+ }
+ }
+ return metadata, nil
+}
diff --git a/modules/packages/conan/conanfile_parser_test.go b/modules/packages/conan/conanfile_parser_test.go
new file mode 100644
index 0000000..fe867fb
--- /dev/null
+++ b/modules/packages/conan/conanfile_parser_test.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ name = "ConanPackage"
+ version = "1.2"
+ license = "MIT"
+ author = "Gitea <info@gitea.io>"
+ homepage = "https://gitea.io/"
+ url = "https://gitea.com/"
+ description = "Description of ConanPackage"
+ topic1 = "gitea"
+ topic2 = "conan"
+ contentConanfile = `from conans import ConanFile, CMake, tools
+
+class ConanPackageConan(ConanFile):
+ name = "` + name + `"
+ version = "` + version + `"
+ license = "` + license + `"
+ author = "` + author + `"
+ homepage = "` + homepage + `"
+ url = "` + url + `"
+ description = "` + description + `"
+ topics = ("` + topic1 + `", "` + topic2 + `")
+ settings = "os", "compiler", "build_type", "arch"
+ options = {"shared": [True, False], "fPIC": [True, False]}
+ default_options = {"shared": False, "fPIC": True}
+ generators = "cmake"
+`
+)
+
+func TestParseConanfile(t *testing.T) {
+ metadata, err := ParseConanfile(strings.NewReader(contentConanfile))
+ require.NoError(t, err)
+ assert.Equal(t, license, metadata.License)
+ assert.Equal(t, author, metadata.Author)
+ assert.Equal(t, homepage, metadata.ProjectURL)
+ assert.Equal(t, url, metadata.RepositoryURL)
+ assert.Equal(t, description, metadata.Description)
+ assert.Equal(t, []string{topic1, topic2}, metadata.Keywords)
+}
diff --git a/modules/packages/conan/conaninfo_parser.go b/modules/packages/conan/conaninfo_parser.go
new file mode 100644
index 0000000..de11dbe
--- /dev/null
+++ b/modules/packages/conan/conaninfo_parser.go
@@ -0,0 +1,123 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "bufio"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Conaninfo represents infos of a Conan package
+type Conaninfo struct {
+ Settings map[string]string `json:"settings"`
+ FullSettings map[string]string `json:"full_settings"`
+ Requires []string `json:"requires"`
+ FullRequires []string `json:"full_requires"`
+ Options map[string]string `json:"options"`
+ FullOptions map[string]string `json:"full_options"`
+ RecipeHash string `json:"recipe_hash"`
+ Environment map[string][]string `json:"environment"`
+}
+
+func ParseConaninfo(r io.Reader) (*Conaninfo, error) {
+ sections, err := readSections(io.LimitReader(r, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+
+ info := &Conaninfo{}
+ for section, lines := range sections {
+ if len(lines) == 0 {
+ continue
+ }
+ switch section {
+ case "settings":
+ info.Settings = toMap(lines)
+ case "full_settings":
+ info.FullSettings = toMap(lines)
+ case "options":
+ info.Options = toMap(lines)
+ case "full_options":
+ info.FullOptions = toMap(lines)
+ case "requires":
+ info.Requires = lines
+ case "full_requires":
+ info.FullRequires = lines
+ case "recipe_hash":
+ info.RecipeHash = lines[0]
+ case "env":
+ info.Environment = toMapArray(lines)
+ }
+ }
+ return info, nil
+}
+
+func readSections(r io.Reader) (map[string][]string, error) {
+ sections := make(map[string][]string)
+
+ section := ""
+ lines := make([]string, 0, 5)
+
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ if section != "" {
+ sections[section] = lines
+ }
+ section = line[1 : len(line)-1]
+ lines = make([]string, 0, 5)
+ continue
+ }
+ if section != "" {
+ if line != "" {
+ lines = append(lines, line)
+ }
+ continue
+ }
+ if line != "" {
+ return nil, util.NewInvalidArgumentErrorf("invalid conaninfo.txt")
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ if section != "" {
+ sections[section] = lines
+ }
+ return sections, nil
+}
+
+func toMap(lines []string) map[string]string {
+ result := make(map[string]string)
+ for _, line := range lines {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
+ continue
+ }
+ result[parts[0]] = parts[1]
+ }
+ return result
+}
+
+func toMapArray(lines []string) map[string][]string {
+ result := make(map[string][]string)
+ for _, line := range lines {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
+ continue
+ }
+ var items []string
+ if strings.HasPrefix(parts[1], "[") && strings.HasSuffix(parts[1], "]") {
+ items = strings.Split(parts[1], ",")
+ } else {
+ items = []string{parts[1]}
+ }
+ result[parts[0]] = items
+ }
+ return result
+}
diff --git a/modules/packages/conan/conaninfo_parser_test.go b/modules/packages/conan/conaninfo_parser_test.go
new file mode 100644
index 0000000..dfb1836
--- /dev/null
+++ b/modules/packages/conan/conaninfo_parser_test.go
@@ -0,0 +1,85 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ settingsKey = "arch"
+ settingsValue = "x84_64"
+ optionsKey = "shared"
+ optionsValue = "False"
+ requires = "fmt/7.1.3"
+ hash = "74714915a51073acb548ca1ce29afbac"
+ envKey = "CC"
+ envValue = "gcc-10"
+
+ contentConaninfo = `[settings]
+ ` + settingsKey + `=` + settingsValue + `
+
+[requires]
+ ` + requires + `
+
+[options]
+ ` + optionsKey + `=` + optionsValue + `
+
+[full_settings]
+ ` + settingsKey + `=` + settingsValue + `
+
+[full_requires]
+ ` + requires + `
+
+[full_options]
+ ` + optionsKey + `=` + optionsValue + `
+
+[recipe_hash]
+ ` + hash + `
+
+[env]
+` + envKey + `=` + envValue + `
+
+`
+)
+
+func TestParseConaninfo(t *testing.T) {
+ info, err := ParseConaninfo(strings.NewReader(contentConaninfo))
+ assert.NotNil(t, info)
+ require.NoError(t, err)
+ assert.Equal(
+ t,
+ map[string]string{
+ settingsKey: settingsValue,
+ },
+ info.Settings,
+ )
+ assert.Equal(t, info.Settings, info.FullSettings)
+ assert.Equal(
+ t,
+ map[string]string{
+ optionsKey: optionsValue,
+ },
+ info.Options,
+ )
+ assert.Equal(t, info.Options, info.FullOptions)
+ assert.Equal(
+ t,
+ []string{requires},
+ info.Requires,
+ )
+ assert.Equal(t, info.Requires, info.FullRequires)
+ assert.Equal(t, hash, info.RecipeHash)
+ assert.Equal(
+ t,
+ map[string][]string{
+ envKey: {envValue},
+ },
+ info.Environment,
+ )
+}
diff --git a/modules/packages/conan/metadata.go b/modules/packages/conan/metadata.go
new file mode 100644
index 0000000..256a376
--- /dev/null
+++ b/modules/packages/conan/metadata.go
@@ -0,0 +1,23 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+const (
+ PropertyRecipeUser = "conan.recipe.user"
+ PropertyRecipeChannel = "conan.recipe.channel"
+ PropertyRecipeRevision = "conan.recipe.revision"
+ PropertyPackageReference = "conan.package.reference"
+ PropertyPackageRevision = "conan.package.revision"
+ PropertyPackageInfo = "conan.package.info"
+)
+
+// Metadata represents the metadata of a Conan package
+type Metadata struct {
+ Author string `json:"author,omitempty"`
+ License string `json:"license,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ Description string `json:"description,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+}
diff --git a/modules/packages/conan/reference.go b/modules/packages/conan/reference.go
new file mode 100644
index 0000000..58f268b
--- /dev/null
+++ b/modules/packages/conan/reference.go
@@ -0,0 +1,155 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ // taken from https://github.com/conan-io/conan/blob/develop/conans/model/ref.py
+ minChars = 2
+ maxChars = 51
+
+ // DefaultRevision if no revision is specified
+ DefaultRevision = "0"
+)
+
+var (
+ namePattern = regexp.MustCompile(fmt.Sprintf(`^[a-zA-Z0-9_][a-zA-Z0-9_\+\.-]{%d,%d}$`, minChars-1, maxChars-1))
+ revisionPattern = regexp.MustCompile(fmt.Sprintf(`^[a-zA-Z0-9]{1,%d}$`, maxChars))
+
+ ErrValidation = util.NewInvalidArgumentErrorf("could not validate one or more reference fields")
+)
+
+// RecipeReference represents a recipe <Name>/<Version>@<User>/<Channel>#<Revision>
+type RecipeReference struct {
+ Name string
+ Version string
+ User string
+ Channel string
+ Revision string
+}
+
+func NewRecipeReference(name, version, user, channel, revision string) (*RecipeReference, error) {
+ log.Trace("Conan Recipe: %s/%s(@%s/%s(#%s))", name, version, user, channel, revision)
+
+ if user == "_" {
+ user = ""
+ }
+ if channel == "_" {
+ channel = ""
+ }
+
+ if (user != "" && channel == "") || (user == "" && channel != "") {
+ return nil, ErrValidation
+ }
+
+ if !namePattern.MatchString(name) {
+ return nil, ErrValidation
+ }
+
+ v := strings.TrimSpace(version)
+ if v == "" {
+ return nil, ErrValidation
+ }
+ if user != "" && !namePattern.MatchString(user) {
+ return nil, ErrValidation
+ }
+ if channel != "" && !namePattern.MatchString(channel) {
+ return nil, ErrValidation
+ }
+ if revision != "" && !revisionPattern.MatchString(revision) {
+ return nil, ErrValidation
+ }
+
+ return &RecipeReference{name, v, user, channel, revision}, nil
+}
+
+func (r *RecipeReference) RevisionOrDefault() string {
+ if r.Revision == "" {
+ return DefaultRevision
+ }
+ return r.Revision
+}
+
+func (r *RecipeReference) String() string {
+ rev := ""
+ if r.Revision != "" {
+ rev = "#" + r.Revision
+ }
+ if r.User == "" || r.Channel == "" {
+ return fmt.Sprintf("%s/%s%s", r.Name, r.Version, rev)
+ }
+ return fmt.Sprintf("%s/%s@%s/%s%s", r.Name, r.Version, r.User, r.Channel, rev)
+}
+
+func (r *RecipeReference) LinkName() string {
+ user := r.User
+ if user == "" {
+ user = "_"
+ }
+ channel := r.Channel
+ if channel == "" {
+ channel = "_"
+ }
+ return fmt.Sprintf("%s/%s/%s/%s/%s", r.Name, r.Version, user, channel, r.RevisionOrDefault())
+}
+
+func (r *RecipeReference) WithRevision(revision string) *RecipeReference {
+ return &RecipeReference{r.Name, r.Version, r.User, r.Channel, revision}
+}
+
+// AsKey builds the additional key for the package file
+func (r *RecipeReference) AsKey() string {
+ return fmt.Sprintf("%s|%s|%s", r.User, r.Channel, r.RevisionOrDefault())
+}
+
+// PackageReference represents a package of a recipe <Name>/<Version>@<User>/<Channel>#<Revision> <Reference>#<Revision>
+type PackageReference struct {
+ Recipe *RecipeReference
+ Reference string
+ Revision string
+}
+
+func NewPackageReference(recipe *RecipeReference, reference, revision string) (*PackageReference, error) {
+ log.Trace("Conan Package: %v %s(#%s)", recipe, reference, revision)
+
+ if recipe == nil {
+ return nil, ErrValidation
+ }
+ if reference == "" || !revisionPattern.MatchString(reference) {
+ return nil, ErrValidation
+ }
+ if revision != "" && !revisionPattern.MatchString(revision) {
+ return nil, ErrValidation
+ }
+
+ return &PackageReference{recipe, reference, revision}, nil
+}
+
+func (r *PackageReference) RevisionOrDefault() string {
+ if r.Revision == "" {
+ return DefaultRevision
+ }
+ return r.Revision
+}
+
+func (r *PackageReference) LinkName() string {
+ return fmt.Sprintf("%s/%s", r.Reference, r.RevisionOrDefault())
+}
+
+func (r *PackageReference) WithRevision(revision string) *PackageReference {
+ return &PackageReference{r.Recipe, r.Reference, revision}
+}
+
+// AsKey builds the additional key for the package file
+func (r *PackageReference) AsKey() string {
+ return fmt.Sprintf("%s|%s|%s|%s|%s", r.Recipe.User, r.Recipe.Channel, r.Recipe.RevisionOrDefault(), r.Reference, r.RevisionOrDefault())
+}
diff --git a/modules/packages/conan/reference_test.go b/modules/packages/conan/reference_test.go
new file mode 100644
index 0000000..7d39bd8
--- /dev/null
+++ b/modules/packages/conan/reference_test.go
@@ -0,0 +1,148 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewRecipeReference(t *testing.T) {
+ cases := []struct {
+ Name string
+ Version string
+ User string
+ Channel string
+ Revision string
+ IsValid bool
+ }{
+ {"", "", "", "", "", false},
+ {"name", "", "", "", "", false},
+ {"", "1.0", "", "", "", false},
+ {"", "", "user", "", "", false},
+ {"", "", "", "channel", "", false},
+ {"", "", "", "", "0", false},
+ {"name", "1.0", "", "", "", true},
+ {"name", "1.0", "user", "", "", false},
+ {"name", "1.0", "", "channel", "", false},
+ {"name", "1.0", "user", "channel", "", true},
+ {"name", "1.0", "_", "", "", true},
+ {"name", "1.0", "", "_", "", true},
+ {"name", "1.0", "_", "_", "", true},
+ {"name", "1.0", "_", "_", "0", true},
+ {"name", "1.0", "", "", "0", true},
+ {"name", "1.0.0q", "", "", "0", true},
+ {"name", "1.0", "", "", "000000000000000000000000000000000000000000000000000000000000", false},
+ }
+
+ for i, c := range cases {
+ rref, err := NewRecipeReference(c.Name, c.Version, c.User, c.Channel, c.Revision)
+ if c.IsValid {
+ require.NoError(t, err, "case %d, should be invalid", i)
+ assert.NotNil(t, rref, "case %d, should not be nil", i)
+ } else {
+ require.Error(t, err, "case %d, should be valid", i)
+ }
+ }
+}
+
+func TestRecipeReferenceRevisionOrDefault(t *testing.T) {
+ rref, err := NewRecipeReference("name", "1.0", "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, DefaultRevision, rref.RevisionOrDefault())
+
+ rref, err = NewRecipeReference("name", "1.0", "", "", DefaultRevision)
+ require.NoError(t, err)
+ assert.Equal(t, DefaultRevision, rref.RevisionOrDefault())
+
+ rref, err = NewRecipeReference("name", "1.0", "", "", "Az09")
+ require.NoError(t, err)
+ assert.Equal(t, "Az09", rref.RevisionOrDefault())
+}
+
+func TestRecipeReferenceString(t *testing.T) {
+ rref, err := NewRecipeReference("name", "1.0", "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0", rref.String())
+
+ rref, err = NewRecipeReference("name", "1.0", "user", "channel", "")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0@user/channel", rref.String())
+
+ rref, err = NewRecipeReference("name", "1.0", "user", "channel", "Az09")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0@user/channel#Az09", rref.String())
+}
+
+func TestRecipeReferenceLinkName(t *testing.T) {
+ rref, err := NewRecipeReference("name", "1.0", "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0/_/_/0", rref.LinkName())
+
+ rref, err = NewRecipeReference("name", "1.0", "user", "channel", "")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0/user/channel/0", rref.LinkName())
+
+ rref, err = NewRecipeReference("name", "1.0", "user", "channel", "Az09")
+ require.NoError(t, err)
+ assert.Equal(t, "name/1.0/user/channel/Az09", rref.LinkName())
+}
+
+func TestNewPackageReference(t *testing.T) {
+ rref, _ := NewRecipeReference("name", "1.0", "", "", "")
+
+ cases := []struct {
+ Recipe *RecipeReference
+ Reference string
+ Revision string
+ IsValid bool
+ }{
+ {nil, "", "", false},
+ {rref, "", "", false},
+ {nil, "aZ09", "", false},
+ {rref, "aZ09", "", true},
+ {rref, "", "Az09", false},
+ {rref, "aZ09", "Az09", true},
+ }
+
+ for i, c := range cases {
+ pref, err := NewPackageReference(c.Recipe, c.Reference, c.Revision)
+ if c.IsValid {
+ require.NoError(t, err, "case %d, should be invalid", i)
+ assert.NotNil(t, pref, "case %d, should not be nil", i)
+ } else {
+ require.Error(t, err, "case %d, should be valid", i)
+ }
+ }
+}
+
+func TestPackageReferenceRevisionOrDefault(t *testing.T) {
+ rref, _ := NewRecipeReference("name", "1.0", "", "", "")
+
+ pref, err := NewPackageReference(rref, "ref", "")
+ require.NoError(t, err)
+ assert.Equal(t, DefaultRevision, pref.RevisionOrDefault())
+
+ pref, err = NewPackageReference(rref, "ref", DefaultRevision)
+ require.NoError(t, err)
+ assert.Equal(t, DefaultRevision, pref.RevisionOrDefault())
+
+ pref, err = NewPackageReference(rref, "ref", "Az09")
+ require.NoError(t, err)
+ assert.Equal(t, "Az09", pref.RevisionOrDefault())
+}
+
+func TestPackageReferenceLinkName(t *testing.T) {
+ rref, _ := NewRecipeReference("name", "1.0", "", "", "")
+
+ pref, err := NewPackageReference(rref, "ref", "")
+ require.NoError(t, err)
+ assert.Equal(t, "ref/0", pref.LinkName())
+
+ pref, err = NewPackageReference(rref, "ref", "Az09")
+ require.NoError(t, err)
+ assert.Equal(t, "ref/Az09", pref.LinkName())
+}
diff --git a/modules/packages/conda/metadata.go b/modules/packages/conda/metadata.go
new file mode 100644
index 0000000..76ba95e
--- /dev/null
+++ b/modules/packages/conda/metadata.go
@@ -0,0 +1,242 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conda
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/bzip2"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+ "code.gitea.io/gitea/modules/zstd"
+)
+
+var (
+ ErrInvalidStructure = util.SilentWrap{Message: "package structure is invalid", Err: util.ErrInvalidArgument}
+ ErrInvalidName = util.SilentWrap{Message: "package name is invalid", Err: util.ErrInvalidArgument}
+ ErrInvalidVersion = util.SilentWrap{Message: "package version is invalid", Err: util.ErrInvalidArgument}
+)
+
+const (
+ PropertyName = "conda.name"
+ PropertyChannel = "conda.channel"
+ PropertySubdir = "conda.subdir"
+ PropertyMetadata = "conda.metadata"
+)
+
+// Package represents a Conda package
+type Package struct {
+ Name string
+ Version string
+ Subdir string
+ VersionMetadata *VersionMetadata
+ FileMetadata *FileMetadata
+}
+
+// VersionMetadata represents the metadata of a Conda package
+type VersionMetadata struct {
+ Description string `json:"description,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ DocumentationURL string `json:"documentation_url,omitempty"`
+ License string `json:"license,omitempty"`
+ LicenseFamily string `json:"license_family,omitempty"`
+}
+
+// FileMetadata represents the metadata of a Conda package file
+type FileMetadata struct {
+ IsCondaPackage bool `json:"is_conda"`
+ Architecture string `json:"architecture,omitempty"`
+ NoArch string `json:"noarch,omitempty"`
+ Build string `json:"build,omitempty"`
+ BuildNumber int64 `json:"build_number,omitempty"`
+ Dependencies []string `json:"dependencies,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ Timestamp int64 `json:"timestamp,omitempty"`
+}
+
+type index struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Architecture string `json:"arch"`
+ NoArch string `json:"noarch"`
+ Build string `json:"build"`
+ BuildNumber int64 `json:"build_number"`
+ Dependencies []string `json:"depends"`
+ License string `json:"license"`
+ LicenseFamily string `json:"license_family"`
+ Platform string `json:"platform"`
+ Subdir string `json:"subdir"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+type about struct {
+ Description string `json:"description"`
+ Summary string `json:"summary"`
+ ProjectURL string `json:"home"`
+ RepositoryURL string `json:"dev_url"`
+ DocumentationURL string `json:"doc_url"`
+}
+
+type ReaderAndReaderAt interface {
+ io.Reader
+ io.ReaderAt
+}
+
+// ParsePackageBZ2 parses the Conda package file compressed with bzip2
+func ParsePackageBZ2(r io.Reader) (*Package, error) {
+ gzr := bzip2.NewReader(r)
+
+ return parsePackageTar(gzr)
+}
+
+// ParsePackageConda parses the Conda package file compressed with zip and zstd
+func ParsePackageConda(r io.ReaderAt, size int64) (*Package, error) {
+ zr, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, file := range zr.File {
+ if strings.HasPrefix(file.Name, "info-") && strings.HasSuffix(file.Name, ".tar.zst") {
+ f, err := zr.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ dec, err := zstd.NewReader(f)
+ if err != nil {
+ return nil, err
+ }
+ defer dec.Close()
+
+ p, err := parsePackageTar(dec)
+ if p != nil {
+ p.FileMetadata.IsCondaPackage = true
+ }
+ return p, err
+ }
+ }
+
+ return nil, ErrInvalidStructure
+}
+
+func parsePackageTar(r io.Reader) (*Package, error) {
+ var i *index
+ var a *about
+
+ tr := tar.NewReader(r)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hdr.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if hdr.Name == "info/index.json" {
+ if err := json.NewDecoder(tr).Decode(&i); err != nil {
+ return nil, err
+ }
+
+ if !checkName(i.Name) {
+ return nil, ErrInvalidName
+ }
+
+ if !checkVersion(i.Version) {
+ return nil, ErrInvalidVersion
+ }
+
+ if a != nil {
+ break // stop loop if both files were found
+ }
+ } else if hdr.Name == "info/about.json" {
+ if err := json.NewDecoder(tr).Decode(&a); err != nil {
+ return nil, err
+ }
+
+ if !validation.IsValidURL(a.ProjectURL) {
+ a.ProjectURL = ""
+ }
+ if !validation.IsValidURL(a.RepositoryURL) {
+ a.RepositoryURL = ""
+ }
+ if !validation.IsValidURL(a.DocumentationURL) {
+ a.DocumentationURL = ""
+ }
+
+ if i != nil {
+ break // stop loop if both files were found
+ }
+ }
+ }
+
+ if i == nil {
+ return nil, ErrInvalidStructure
+ }
+ if a == nil {
+ a = &about{}
+ }
+
+ return &Package{
+ Name: i.Name,
+ Version: i.Version,
+ Subdir: i.Subdir,
+ VersionMetadata: &VersionMetadata{
+ License: i.License,
+ LicenseFamily: i.LicenseFamily,
+ Description: a.Description,
+ Summary: a.Summary,
+ ProjectURL: a.ProjectURL,
+ RepositoryURL: a.RepositoryURL,
+ DocumentationURL: a.DocumentationURL,
+ },
+ FileMetadata: &FileMetadata{
+ Architecture: i.Architecture,
+ NoArch: i.NoArch,
+ Build: i.Build,
+ BuildNumber: i.BuildNumber,
+ Dependencies: i.Dependencies,
+ Platform: i.Platform,
+ Timestamp: i.Timestamp,
+ },
+ }, nil
+}
+
+// https://github.com/conda/conda-build/blob/db9a728a9e4e6cfc895637ca3221117970fc2663/conda_build/metadata.py#L1393
+func checkName(name string) bool {
+ if name == "" {
+ return false
+ }
+ if name != strings.ToLower(name) {
+ return false
+ }
+ return !checkBadCharacters(name, "!")
+}
+
+// https://github.com/conda/conda-build/blob/db9a728a9e4e6cfc895637ca3221117970fc2663/conda_build/metadata.py#L1403
+func checkVersion(version string) bool {
+ if version == "" {
+ return false
+ }
+ return !checkBadCharacters(version, "-")
+}
+
+func checkBadCharacters(s, additional string) bool {
+ if strings.ContainsAny(s, "=@#$%^&*:;\"'\\|<>?/ ") {
+ return true
+ }
+ return strings.ContainsAny(s, additional)
+}
diff --git a/modules/packages/conda/metadata_test.go b/modules/packages/conda/metadata_test.go
new file mode 100644
index 0000000..25b0295
--- /dev/null
+++ b/modules/packages/conda/metadata_test.go
@@ -0,0 +1,152 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conda
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "io"
+ "testing"
+
+ "code.gitea.io/gitea/modules/zstd"
+
+ "github.com/dsnet/compress/bzip2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ description = "Package Description"
+ projectURL = "https://gitea.com"
+ repositoryURL = "https://gitea.com/gitea/gitea"
+ documentationURL = "https://docs.gitea.com"
+)
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string][]byte) *bytes.Buffer {
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ for filename, content := range files {
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ }
+ tw.Close()
+ return &buf
+ }
+
+ t.Run("MissingIndexFile", func(t *testing.T) {
+ buf := createArchive(map[string][]byte{"dummy.txt": {}})
+
+ p, err := parsePackageTar(buf)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidStructure)
+ })
+
+ t.Run("MissingAboutFile", func(t *testing.T) {
+ buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"name","version":"1.0"}`)})
+
+ p, err := parsePackageTar(buf)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "name", p.Name)
+ assert.Equal(t, "1.0", p.Version)
+ assert.Empty(t, p.VersionMetadata.ProjectURL)
+ })
+
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{"", "name!", "nAMe"} {
+ buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"` + name + `","version":"1.0"}`)})
+
+ p, err := parsePackageTar(buf)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ for _, version := range []string{"", "1.0-2"} {
+ buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"name","version":"` + version + `"}`)})
+
+ p, err := parsePackageTar(buf)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ }
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ buf := createArchive(map[string][]byte{
+ "info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `","subdir":"linux-64"}`),
+ "info/about.json": []byte(`{"description":"` + description + `","dev_url":"` + repositoryURL + `","doc_url":"` + documentationURL + `","home":"` + projectURL + `"}`),
+ })
+
+ p, err := parsePackageTar(buf)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, "linux-64", p.Subdir)
+ assert.Equal(t, description, p.VersionMetadata.Description)
+ assert.Equal(t, projectURL, p.VersionMetadata.ProjectURL)
+ assert.Equal(t, repositoryURL, p.VersionMetadata.RepositoryURL)
+ assert.Equal(t, documentationURL, p.VersionMetadata.DocumentationURL)
+ })
+
+ t.Run(".tar.bz2", func(t *testing.T) {
+ tarArchive := createArchive(map[string][]byte{
+ "info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `"}`),
+ })
+
+ var buf bytes.Buffer
+ bw, _ := bzip2.NewWriter(&buf, nil)
+ io.Copy(bw, tarArchive)
+ bw.Close()
+
+ br := bytes.NewReader(buf.Bytes())
+
+ p, err := ParsePackageBZ2(br)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.False(t, p.FileMetadata.IsCondaPackage)
+ })
+
+ t.Run(".conda", func(t *testing.T) {
+ tarArchive := createArchive(map[string][]byte{
+ "info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `"}`),
+ })
+
+ var infoBuf bytes.Buffer
+ zsw, _ := zstd.NewWriter(&infoBuf)
+ io.Copy(zsw, tarArchive)
+ zsw.Close()
+
+ var buf bytes.Buffer
+ zpw := zip.NewWriter(&buf)
+ w, _ := zpw.Create("info-x.tar.zst")
+ w.Write(infoBuf.Bytes())
+ zpw.Close()
+
+ br := bytes.NewReader(buf.Bytes())
+
+ p, err := ParsePackageConda(br, int64(br.Len()))
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.True(t, p.FileMetadata.IsCondaPackage)
+ })
+}
diff --git a/modules/packages/container/helm/helm.go b/modules/packages/container/helm/helm.go
new file mode 100644
index 0000000..6981d43
--- /dev/null
+++ b/modules/packages/container/helm/helm.go
@@ -0,0 +1,55 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package helm
+
+// https://github.com/helm/helm/blob/main/pkg/chart/
+
+const ConfigMediaType = "application/vnd.cncf.helm.config.v1+json"
+
+// Maintainer describes a Chart maintainer.
+type Maintainer struct {
+ // Name is a user name or organization name
+ Name string `json:"name,omitempty"`
+ // Email is an optional email address to contact the named maintainer
+ Email string `json:"email,omitempty"`
+ // URL is an optional URL to an address for the named maintainer
+ URL string `json:"url,omitempty"`
+}
+
+// Metadata for a Chart file. This models the structure of a Chart.yaml file.
+type Metadata struct {
+ // The name of the chart. Required.
+ Name string `json:"name,omitempty"`
+ // The URL to a relevant project page, git repo, or contact person
+ Home string `json:"home,omitempty"`
+ // Source is the URL to the source code of this chart
+ Sources []string `json:"sources,omitempty"`
+ // A SemVer 2 conformant version string of the chart. Required.
+ Version string `json:"version,omitempty"`
+ // A one-sentence description of the chart
+ Description string `json:"description,omitempty"`
+ // A list of string keywords
+ Keywords []string `json:"keywords,omitempty"`
+ // A list of name and URL/email address combinations for the maintainer(s)
+ Maintainers []*Maintainer `json:"maintainers,omitempty"`
+ // The URL to an icon file.
+ Icon string `json:"icon,omitempty"`
+ // The API Version of this chart. Required.
+ APIVersion string `json:"apiVersion,omitempty"`
+ // The condition to check to enable chart
+ Condition string `json:"condition,omitempty"`
+ // The tags to check to enable chart
+ Tags string `json:"tags,omitempty"`
+ // The version of the application enclosed inside of this chart.
+ AppVersion string `json:"appVersion,omitempty"`
+ // Whether or not this chart is deprecated
+ Deprecated bool `json:"deprecated,omitempty"`
+ // Annotations are additional mappings uninterpreted by Helm,
+ // made available for inspection by other applications.
+ Annotations map[string]string `json:"annotations,omitempty"`
+ // KubeVersion is a SemVer constraint specifying the version of Kubernetes required.
+ KubeVersion string `json:"kubeVersion,omitempty"`
+ // Specifies the chart type: application or library
+ Type string `json:"type,omitempty"`
+}
diff --git a/modules/packages/container/metadata.go b/modules/packages/container/metadata.go
new file mode 100644
index 0000000..2a41fb9
--- /dev/null
+++ b/modules/packages/container/metadata.go
@@ -0,0 +1,166 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/packages/container/helm"
+ "code.gitea.io/gitea/modules/validation"
+
+ oci "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+const (
+ PropertyRepository = "container.repository"
+ PropertyDigest = "container.digest"
+ PropertyMediaType = "container.mediatype"
+ PropertyManifestTagged = "container.manifest.tagged"
+ PropertyManifestReference = "container.manifest.reference"
+
+ DefaultPlatform = "linux/amd64"
+
+ labelLicenses = "org.opencontainers.image.licenses"
+ labelURL = "org.opencontainers.image.url"
+ labelSource = "org.opencontainers.image.source"
+ labelDocumentation = "org.opencontainers.image.documentation"
+ labelDescription = "org.opencontainers.image.description"
+ labelAuthors = "org.opencontainers.image.authors"
+)
+
+type ImageType string
+
+const (
+ TypeOCI ImageType = "oci"
+ TypeHelm ImageType = "helm"
+)
+
+// Name gets the name of the image type
+func (it ImageType) Name() string {
+ switch it {
+ case TypeHelm:
+ return "Helm Chart"
+ default:
+ return "OCI / Docker"
+ }
+}
+
+// Metadata represents the metadata of a Container package
+type Metadata struct {
+ Type ImageType `json:"type"`
+ IsTagged bool `json:"is_tagged"`
+ Platform string `json:"platform,omitempty"`
+ Description string `json:"description,omitempty"`
+ Authors []string `json:"authors,omitempty"`
+ Licenses string `json:"license,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ DocumentationURL string `json:"documentation_url,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+ ImageLayers []string `json:"layer_creation,omitempty"`
+ Manifests []*Manifest `json:"manifests,omitempty"`
+}
+
+type Manifest struct {
+ Platform string `json:"platform"`
+ Digest string `json:"digest"`
+ Size int64 `json:"size"`
+}
+
+// ParseImageConfig parses the metadata of an image config
+func ParseImageConfig(mt string, r io.Reader) (*Metadata, error) {
+ if strings.EqualFold(mt, helm.ConfigMediaType) {
+ return parseHelmConfig(r)
+ }
+
+ // fallback to OCI Image Config
+ return parseOCIImageConfig(r)
+}
+
+func parseOCIImageConfig(r io.Reader) (*Metadata, error) {
+ var image oci.Image
+ if err := json.NewDecoder(r).Decode(&image); err != nil {
+ return nil, err
+ }
+
+ platform := DefaultPlatform
+ if image.OS != "" && image.Architecture != "" {
+ platform = fmt.Sprintf("%s/%s", image.OS, image.Architecture)
+ if image.Variant != "" {
+ platform = fmt.Sprintf("%s/%s", platform, image.Variant)
+ }
+ }
+
+ imageLayers := make([]string, 0, len(image.History))
+ for _, history := range image.History {
+ cmd := history.CreatedBy
+ if i := strings.Index(cmd, "#(nop) "); i != -1 {
+ cmd = strings.TrimSpace(cmd[i+7:])
+ }
+ if cmd != "" {
+ imageLayers = append(imageLayers, cmd)
+ }
+ }
+
+ metadata := &Metadata{
+ Type: TypeOCI,
+ Platform: platform,
+ Licenses: image.Config.Labels[labelLicenses],
+ ProjectURL: image.Config.Labels[labelURL],
+ RepositoryURL: image.Config.Labels[labelSource],
+ DocumentationURL: image.Config.Labels[labelDocumentation],
+ Description: image.Config.Labels[labelDescription],
+ Labels: image.Config.Labels,
+ ImageLayers: imageLayers,
+ }
+
+ if authors, ok := image.Config.Labels[labelAuthors]; ok {
+ metadata.Authors = []string{authors}
+ }
+
+ if !validation.IsValidURL(metadata.ProjectURL) {
+ metadata.ProjectURL = ""
+ }
+ if !validation.IsValidURL(metadata.RepositoryURL) {
+ metadata.RepositoryURL = ""
+ }
+ if !validation.IsValidURL(metadata.DocumentationURL) {
+ metadata.DocumentationURL = ""
+ }
+
+ return metadata, nil
+}
+
+func parseHelmConfig(r io.Reader) (*Metadata, error) {
+ var config helm.Metadata
+ if err := json.NewDecoder(r).Decode(&config); err != nil {
+ return nil, err
+ }
+
+ metadata := &Metadata{
+ Type: TypeHelm,
+ Description: config.Description,
+ ProjectURL: config.Home,
+ }
+
+ if len(config.Maintainers) > 0 {
+ authors := make([]string, 0, len(config.Maintainers))
+ for _, maintainer := range config.Maintainers {
+ authors = append(authors, maintainer.Name)
+ }
+ metadata.Authors = authors
+ }
+
+ if len(config.Sources) > 0 && validation.IsValidURL(config.Sources[0]) {
+ metadata.RepositoryURL = config.Sources[0]
+ }
+ if !validation.IsValidURL(metadata.ProjectURL) {
+ metadata.ProjectURL = ""
+ }
+
+ return metadata, nil
+}
diff --git a/modules/packages/container/metadata_test.go b/modules/packages/container/metadata_test.go
new file mode 100644
index 0000000..930cf48
--- /dev/null
+++ b/modules/packages/container/metadata_test.go
@@ -0,0 +1,62 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/packages/container/helm"
+
+ oci "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseImageConfig(t *testing.T) {
+ description := "Image Description"
+ author := "Gitea"
+ license := "MIT"
+ projectURL := "https://gitea.com"
+ repositoryURL := "https://gitea.com/gitea"
+ documentationURL := "https://docs.gitea.com"
+
+ configOCI := `{"config": {"labels": {"` + labelAuthors + `": "` + author + `", "` + labelLicenses + `": "` + license + `", "` + labelURL + `": "` + projectURL + `", "` + labelSource + `": "` + repositoryURL + `", "` + labelDocumentation + `": "` + documentationURL + `", "` + labelDescription + `": "` + description + `"}}, "history": [{"created_by": "do it 1"}, {"created_by": "dummy #(nop) do it 2"}]}`
+
+ metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(configOCI))
+ require.NoError(t, err)
+
+ assert.Equal(t, TypeOCI, metadata.Type)
+ assert.Equal(t, description, metadata.Description)
+ assert.ElementsMatch(t, []string{author}, metadata.Authors)
+ assert.Equal(t, license, metadata.Licenses)
+ assert.Equal(t, projectURL, metadata.ProjectURL)
+ assert.Equal(t, repositoryURL, metadata.RepositoryURL)
+ assert.Equal(t, documentationURL, metadata.DocumentationURL)
+ assert.ElementsMatch(t, []string{"do it 1", "do it 2"}, metadata.ImageLayers)
+ assert.Equal(
+ t,
+ map[string]string{
+ labelAuthors: author,
+ labelLicenses: license,
+ labelURL: projectURL,
+ labelSource: repositoryURL,
+ labelDocumentation: documentationURL,
+ labelDescription: description,
+ },
+ metadata.Labels,
+ )
+ assert.Empty(t, metadata.Manifests)
+
+ configHelm := `{"description":"` + description + `", "home": "` + projectURL + `", "sources": ["` + repositoryURL + `"], "maintainers":[{"name":"` + author + `"}]}`
+
+ metadata, err = ParseImageConfig(helm.ConfigMediaType, strings.NewReader(configHelm))
+ require.NoError(t, err)
+
+ assert.Equal(t, TypeHelm, metadata.Type)
+ assert.Equal(t, description, metadata.Description)
+ assert.ElementsMatch(t, []string{author}, metadata.Authors)
+ assert.Equal(t, projectURL, metadata.ProjectURL)
+ assert.Equal(t, repositoryURL, metadata.RepositoryURL)
+}
diff --git a/modules/packages/content_store.go b/modules/packages/content_store.go
new file mode 100644
index 0000000..da93e6c
--- /dev/null
+++ b/modules/packages/content_store.go
@@ -0,0 +1,75 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "io"
+ "net/url"
+ "path"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// BlobHash256Key is the key to address a blob content
+type BlobHash256Key string
+
+// ContentStore is a wrapper around ObjectStorage
+type ContentStore struct {
+ store storage.ObjectStorage
+}
+
+// NewContentStore creates the default package store
+func NewContentStore() *ContentStore {
+ contentStore := &ContentStore{storage.Packages}
+ return contentStore
+}
+
+// Get gets a package blob
+func (s *ContentStore) Get(key BlobHash256Key) (storage.Object, error) {
+ return s.store.Open(KeyToRelativePath(key))
+}
+
+func (s *ContentStore) ShouldServeDirect() bool {
+ return setting.Packages.Storage.MinioConfig.ServeDirect
+}
+
+func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename string) (*url.URL, error) {
+ return s.store.URL(KeyToRelativePath(key), filename)
+}
+
+// FIXME: Workaround to be removed in v1.20
+// https://github.com/go-gitea/gitea/issues/19586
+func (s *ContentStore) Has(key BlobHash256Key) error {
+ _, err := s.store.Stat(KeyToRelativePath(key))
+ return err
+}
+
+// Save stores a package blob
+func (s *ContentStore) Save(key BlobHash256Key, r io.Reader, size int64) error {
+ _, err := s.store.Save(KeyToRelativePath(key), r, size)
+ return err
+}
+
+// Delete deletes a package blob
+func (s *ContentStore) Delete(key BlobHash256Key) error {
+ return s.store.Delete(KeyToRelativePath(key))
+}
+
+// KeyToRelativePath converts the sha256 key aabb000000... to aa/bb/aabb000000...
+func KeyToRelativePath(key BlobHash256Key) string {
+ return path.Join(string(key)[0:2], string(key)[2:4], string(key))
+}
+
+// RelativePathToKey converts a relative path aa/bb/aabb000000... to the sha256 key aabb000000...
+func RelativePathToKey(relativePath string) (BlobHash256Key, error) {
+ parts := strings.SplitN(relativePath, "/", 3)
+ if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) < 4 || parts[0]+parts[1] != parts[2][0:4] {
+ return "", util.ErrInvalidArgument
+ }
+
+ return BlobHash256Key(parts[2]), nil
+}
diff --git a/modules/packages/cran/metadata.go b/modules/packages/cran/metadata.go
new file mode 100644
index 0000000..0b0bfb0
--- /dev/null
+++ b/modules/packages/cran/metadata.go
@@ -0,0 +1,242 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cran
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bufio"
+ "compress/gzip"
+ "io"
+ "path"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ PropertyType = "cran.type"
+ PropertyPlatform = "cran.platform"
+ PropertyRVersion = "cran.rvserion"
+
+ TypeSource = "source"
+ TypeBinary = "binary"
+)
+
+var (
+ ErrMissingDescriptionFile = util.NewInvalidArgumentErrorf("DESCRIPTION file is missing")
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+)
+
+var (
+ fieldPattern = regexp.MustCompile(`\A\S+:`)
+ namePattern = regexp.MustCompile(`\A[a-zA-Z][a-zA-Z0-9\.]*[a-zA-Z0-9]\z`)
+ versionPattern = regexp.MustCompile(`\A[0-9]+(?:[.\-][0-9]+){1,3}\z`)
+ authorReplacePattern = regexp.MustCompile(`[\[\(].+?[\]\)]`)
+)
+
+// Package represents a CRAN package
+type Package struct {
+ Name string
+ Version string
+ FileExtension string
+ Metadata *Metadata
+}
+
+// Metadata represents the metadata of a CRAN package
+type Metadata struct {
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+ ProjectURL []string `json:"project_url,omitempty"`
+ License string `json:"license,omitempty"`
+ Authors []string `json:"authors,omitempty"`
+ Depends []string `json:"depends,omitempty"`
+ Imports []string `json:"imports,omitempty"`
+ Suggests []string `json:"suggests,omitempty"`
+ LinkingTo []string `json:"linking_to,omitempty"`
+ NeedsCompilation bool `json:"needs_compilation"`
+}
+
+type ReaderReaderAt interface {
+ io.Reader
+ io.ReaderAt
+}
+
+// ParsePackage reads the package metadata from a CRAN package
+// .zip and .tar.gz/.tgz files are supported.
+func ParsePackage(r ReaderReaderAt, size int64) (*Package, error) {
+ magicBytes := make([]byte, 2)
+ if _, err := r.ReadAt(magicBytes, 0); err != nil {
+ return nil, err
+ }
+
+ if magicBytes[0] == 0x1F && magicBytes[1] == 0x8B {
+ return parsePackageTarGz(r)
+ }
+ return parsePackageZip(r, size)
+}
+
+func parsePackageTarGz(r io.Reader) (*Package, error) {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if strings.Count(hd.Name, "/") > 1 {
+ continue
+ }
+
+ if path.Base(hd.Name) == "DESCRIPTION" {
+ p, err := ParseDescription(tr)
+ if p != nil {
+ p.FileExtension = ".tar.gz"
+ }
+ return p, err
+ }
+ }
+
+ return nil, ErrMissingDescriptionFile
+}
+
+func parsePackageZip(r io.ReaderAt, size int64) (*Package, error) {
+ zr, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, file := range zr.File {
+ if strings.Count(file.Name, "/") > 1 {
+ continue
+ }
+
+ if path.Base(file.Name) == "DESCRIPTION" {
+ f, err := zr.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ p, err := ParseDescription(f)
+ if p != nil {
+ p.FileExtension = ".zip"
+ }
+ return p, err
+ }
+ }
+
+ return nil, ErrMissingDescriptionFile
+}
+
+// ParseDescription parses a DESCRIPTION file to retrieve the metadata of a CRAN package
+func ParseDescription(r io.Reader) (*Package, error) {
+ p := &Package{
+ Metadata: &Metadata{},
+ }
+
+ scanner := bufio.NewScanner(r)
+
+ var b strings.Builder
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ if !fieldPattern.MatchString(line) {
+ b.WriteRune(' ')
+ b.WriteString(line)
+ continue
+ }
+
+ if err := setField(p, b.String()); err != nil {
+ return nil, err
+ }
+
+ b.Reset()
+ b.WriteString(line)
+ }
+
+ if err := setField(p, b.String()); err != nil {
+ return nil, err
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return p, nil
+}
+
+func setField(p *Package, data string) error {
+ if data == "" {
+ return nil
+ }
+
+ parts := strings.SplitN(data, ":", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+
+ name := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ switch name {
+ case "Package":
+ if !namePattern.MatchString(value) {
+ return ErrInvalidName
+ }
+ p.Name = value
+ case "Version":
+ if !versionPattern.MatchString(value) {
+ return ErrInvalidVersion
+ }
+ p.Version = value
+ case "Title":
+ p.Metadata.Title = value
+ case "Description":
+ p.Metadata.Description = value
+ case "URL":
+ p.Metadata.ProjectURL = splitAndTrim(value)
+ case "License":
+ p.Metadata.License = value
+ case "Author":
+ p.Metadata.Authors = splitAndTrim(authorReplacePattern.ReplaceAllString(value, ""))
+ case "Depends":
+ p.Metadata.Depends = splitAndTrim(value)
+ case "Imports":
+ p.Metadata.Imports = splitAndTrim(value)
+ case "Suggests":
+ p.Metadata.Suggests = splitAndTrim(value)
+ case "LinkingTo":
+ p.Metadata.LinkingTo = splitAndTrim(value)
+ case "NeedsCompilation":
+ p.Metadata.NeedsCompilation = value == "yes"
+ }
+
+ return nil
+}
+
+func splitAndTrim(s string) []string {
+ items := strings.Split(s, ", ")
+ for i := range items {
+ items[i] = strings.TrimSpace(items[i])
+ }
+ return items
+}
diff --git a/modules/packages/cran/metadata_test.go b/modules/packages/cran/metadata_test.go
new file mode 100644
index 0000000..3287380
--- /dev/null
+++ b/modules/packages/cran/metadata_test.go
@@ -0,0 +1,153 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cran
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ author = "KN4CK3R"
+ description = "Package Description"
+ projectURL = "https://gitea.io"
+ license = "GPL (>= 2)"
+)
+
+func createDescription(name, version string) *bytes.Buffer {
+ var buf bytes.Buffer
+ fmt.Fprintln(&buf, "Package:", name)
+ fmt.Fprintln(&buf, "Version:", version)
+ fmt.Fprintln(&buf, "Description:", "Package\n\n Description")
+ fmt.Fprintln(&buf, "URL:", projectURL)
+ fmt.Fprintln(&buf, "Imports: abc,\n123")
+ fmt.Fprintln(&buf, "NeedsCompilation: yes")
+ fmt.Fprintln(&buf, "License:", license)
+ fmt.Fprintln(&buf, "Author:", author)
+ return &buf
+}
+
+func TestParsePackage(t *testing.T) {
+ t.Run(".tar.gz", func(t *testing.T) {
+ createArchive := func(filename string, content []byte) *bytes.Reader {
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ tw.Close()
+ gw.Close()
+ return bytes.NewReader(buf.Bytes())
+ }
+
+ t.Run("MissingDescriptionFile", func(t *testing.T) {
+ buf := createArchive(
+ "dummy.txt",
+ []byte{},
+ )
+
+ p, err := ParsePackage(buf, buf.Size())
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrMissingDescriptionFile)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ buf := createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion).Bytes(),
+ )
+
+ p, err := ParsePackage(buf, buf.Size())
+
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ })
+ })
+
+ t.Run(".zip", func(t *testing.T) {
+ createArchive := func(filename string, content []byte) *bytes.Reader {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create(filename)
+ w.Write(content)
+ archive.Close()
+ return bytes.NewReader(buf.Bytes())
+ }
+
+ t.Run("MissingDescriptionFile", func(t *testing.T) {
+ buf := createArchive(
+ "dummy.txt",
+ []byte{},
+ )
+
+ p, err := ParsePackage(buf, buf.Size())
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrMissingDescriptionFile)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ buf := createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion).Bytes(),
+ )
+
+ p, err := ParsePackage(buf, buf.Size())
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ })
+ })
+}
+
+func TestParseDescription(t *testing.T) {
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{"123abc", "ab-cd", "ab cd", "ab/cd"} {
+ p, err := ParseDescription(createDescription(name, packageVersion))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ for _, version := range []string{"1", "1 0", "1.2.3.4.5", "1-2-3-4-5", "1.", "1.0.", "1-", "1-0-"} {
+ p, err := ParseDescription(createDescription(packageName, version))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ }
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ p, err := ParseDescription(createDescription(packageName, packageVersion))
+ require.NoError(t, err)
+ assert.NotNil(t, p)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, description, p.Metadata.Description)
+ assert.ElementsMatch(t, []string{projectURL}, p.Metadata.ProjectURL)
+ assert.ElementsMatch(t, []string{author}, p.Metadata.Authors)
+ assert.Equal(t, license, p.Metadata.License)
+ assert.ElementsMatch(t, []string{"abc", "123"}, p.Metadata.Imports)
+ assert.True(t, p.Metadata.NeedsCompilation)
+ })
+}
diff --git a/modules/packages/debian/metadata.go b/modules/packages/debian/metadata.go
new file mode 100644
index 0000000..e76db63
--- /dev/null
+++ b/modules/packages/debian/metadata.go
@@ -0,0 +1,221 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package debian
+
+import (
+ "archive/tar"
+ "bufio"
+ "compress/gzip"
+ "io"
+ "net/mail"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+ "code.gitea.io/gitea/modules/zstd"
+
+ "github.com/blakesmith/ar"
+ "github.com/ulikunitz/xz"
+)
+
+const (
+ PropertyDistribution = "debian.distribution"
+ PropertyComponent = "debian.component"
+ PropertyArchitecture = "debian.architecture"
+ PropertyControl = "debian.control"
+ PropertyRepositoryIncludeInRelease = "debian.repository.include_in_release"
+
+ SettingKeyPrivate = "debian.key.private"
+ SettingKeyPublic = "debian.key.public"
+
+ RepositoryPackage = "_debian"
+ RepositoryVersion = "_repository"
+
+ controlTar = "control.tar"
+)
+
+var (
+ ErrMissingControlFile = util.NewInvalidArgumentErrorf("control file is missing")
+ ErrUnsupportedCompression = util.NewInvalidArgumentErrorf("unsupported compression algorithm")
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+ ErrInvalidArchitecture = util.NewInvalidArgumentErrorf("package architecture is invalid")
+
+ // https://www.debian.org/doc/debian-policy/ch-controlfields.html#source
+ namePattern = regexp.MustCompile(`\A[a-z0-9][a-z0-9+-.]+\z`)
+ // https://www.debian.org/doc/debian-policy/ch-controlfields.html#version
+ versionPattern = regexp.MustCompile(`\A(?:[0-9]:)?[a-zA-Z0-9.+~]+(?:-[a-zA-Z0-9.+-~]+)?\z`)
+)
+
+type Package struct {
+ Name string
+ Version string
+ Architecture string
+ Control string
+ Metadata *Metadata
+}
+
+type Metadata struct {
+ Maintainer string `json:"maintainer,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Description string `json:"description,omitempty"`
+ Dependencies []string `json:"dependencies,omitempty"`
+}
+
+// ParsePackage parses the Debian package file
+// https://manpages.debian.org/bullseye/dpkg-dev/deb.5.en.html
+func ParsePackage(r io.Reader) (*Package, error) {
+ arr := ar.NewReader(r)
+
+ for {
+ hd, err := arr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if strings.HasPrefix(hd.Name, controlTar) {
+ var inner io.Reader
+ // https://man7.org/linux/man-pages/man5/deb-split.5.html#FORMAT
+ // The file names might contain a trailing slash (since dpkg 1.15.6).
+ switch strings.TrimSuffix(hd.Name[len(controlTar):], "/") {
+ case "":
+ inner = arr
+ case ".gz":
+ gzr, err := gzip.NewReader(arr)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ inner = gzr
+ case ".xz":
+ xzr, err := xz.NewReader(arr)
+ if err != nil {
+ return nil, err
+ }
+
+ inner = xzr
+ case ".zst":
+ zr, err := zstd.NewReader(arr)
+ if err != nil {
+ return nil, err
+ }
+ defer zr.Close()
+
+ inner = zr
+ default:
+ return nil, ErrUnsupportedCompression
+ }
+
+ tr := tar.NewReader(inner)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if hd.FileInfo().Name() == "control" {
+ return ParseControlFile(tr)
+ }
+ }
+ }
+ }
+
+ return nil, ErrMissingControlFile
+}
+
+// ParseControlFile parses a Debian control file to retrieve the metadata
+func ParseControlFile(r io.Reader) (*Package, error) {
+ p := &Package{
+ Metadata: &Metadata{},
+ }
+
+ key := ""
+ var depends strings.Builder
+ var control strings.Builder
+
+ s := bufio.NewScanner(io.TeeReader(r, &control))
+ for s.Scan() {
+ line := s.Text()
+
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+
+ if line[0] == ' ' || line[0] == '\t' {
+ switch key {
+ case "Description":
+ p.Metadata.Description += line
+ case "Depends":
+ depends.WriteString(trimmed)
+ }
+ } else {
+ parts := strings.SplitN(trimmed, ":", 2)
+ if len(parts) < 2 {
+ continue
+ }
+
+ key = parts[0]
+ value := strings.TrimSpace(parts[1])
+ switch key {
+ case "Package":
+ p.Name = value
+ case "Version":
+ p.Version = value
+ case "Architecture":
+ p.Architecture = value
+ case "Maintainer":
+ a, err := mail.ParseAddress(value)
+ if err != nil || a.Name == "" {
+ p.Metadata.Maintainer = value
+ } else {
+ p.Metadata.Maintainer = a.Name
+ }
+ case "Description":
+ p.Metadata.Description = value
+ case "Depends":
+ depends.WriteString(value)
+ case "Homepage":
+ if validation.IsValidURL(value) {
+ p.Metadata.ProjectURL = value
+ }
+ }
+ }
+ }
+ if err := s.Err(); err != nil {
+ return nil, err
+ }
+
+ if !namePattern.MatchString(p.Name) {
+ return nil, ErrInvalidName
+ }
+ if !versionPattern.MatchString(p.Version) {
+ return nil, ErrInvalidVersion
+ }
+ if p.Architecture == "" {
+ return nil, ErrInvalidArchitecture
+ }
+
+ dependencies := strings.Split(depends.String(), ",")
+ for i := range dependencies {
+ dependencies[i] = strings.TrimSpace(dependencies[i])
+ }
+ p.Metadata.Dependencies = dependencies
+
+ p.Control = strings.TrimSpace(control.String())
+
+ return p, nil
+}
diff --git a/modules/packages/debian/metadata_test.go b/modules/packages/debian/metadata_test.go
new file mode 100644
index 0000000..6f6c469
--- /dev/null
+++ b/modules/packages/debian/metadata_test.go
@@ -0,0 +1,187 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package debian
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "testing"
+
+ "code.gitea.io/gitea/modules/zstd"
+
+ "github.com/blakesmith/ar"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/ulikunitz/xz"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "0:1.0.1-te~st"
+ packageArchitecture = "amd64"
+ packageAuthor = "KN4CK3R"
+ description = "Description with multiple lines."
+ projectURL = "https://gitea.io"
+)
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string][]byte) io.Reader {
+ var buf bytes.Buffer
+ aw := ar.NewWriter(&buf)
+ aw.WriteGlobalHeader()
+ for filename, content := range files {
+ hdr := &ar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ aw.WriteHeader(hdr)
+ aw.Write(content)
+ }
+ return &buf
+ }
+
+ t.Run("MissingControlFile", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"dummy.txt": {}})
+
+ p, err := ParsePackage(data)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrMissingControlFile)
+ })
+
+ t.Run("Compression", func(t *testing.T) {
+ t.Run("Unsupported", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"control.tar.foo": {}})
+
+ p, err := ParsePackage(data)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrUnsupportedCompression)
+ })
+
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ tw.WriteHeader(&tar.Header{
+ Name: "control",
+ Mode: 0o600,
+ Size: 50,
+ })
+ tw.Write([]byte("Package: gitea\nVersion: 1.0.0\nArchitecture: amd64\n"))
+ tw.Close()
+
+ cases := []struct {
+ Extension string
+ WriterFactory func(io.Writer) io.WriteCloser
+ }{
+ {
+ Extension: "",
+ WriterFactory: func(w io.Writer) io.WriteCloser {
+ return nopCloser{w}
+ },
+ },
+ {
+ Extension: ".gz",
+ WriterFactory: func(w io.Writer) io.WriteCloser {
+ return gzip.NewWriter(w)
+ },
+ },
+ {
+ Extension: ".xz",
+ WriterFactory: func(w io.Writer) io.WriteCloser {
+ xw, _ := xz.NewWriter(w)
+ return xw
+ },
+ },
+ {
+ Extension: ".zst",
+ WriterFactory: func(w io.Writer) io.WriteCloser {
+ zw, _ := zstd.NewWriter(w)
+ return zw
+ },
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Extension, func(t *testing.T) {
+ var cbuf bytes.Buffer
+ w := c.WriterFactory(&cbuf)
+ w.Write(buf.Bytes())
+ w.Close()
+
+ data := createArchive(map[string][]byte{"control.tar" + c.Extension: cbuf.Bytes()})
+
+ p, err := ParsePackage(data)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+ assert.Equal(t, "gitea", p.Name)
+
+ t.Run("TrailingSlash", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"control.tar" + c.Extension + "/": cbuf.Bytes()})
+
+ p, err := ParsePackage(data)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+ assert.Equal(t, "gitea", p.Name)
+ })
+ })
+ }
+ })
+}
+
+type nopCloser struct {
+ io.Writer
+}
+
+func (nopCloser) Close() error {
+ return nil
+}
+
+func TestParseControlFile(t *testing.T) {
+ buildContent := func(name, version, architecture string) *bytes.Buffer {
+ var buf bytes.Buffer
+ buf.WriteString("Package: " + name + "\nVersion: " + version + "\nArchitecture: " + architecture + "\nMaintainer: " + packageAuthor + " <kn4ck3r@gitea.io>\nHomepage: " + projectURL + "\nDepends: a,\n b\nDescription: Description\n with multiple\n lines.")
+ return &buf
+ }
+
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{"", "-cd"} {
+ p, err := ParseControlFile(buildContent(name, packageVersion, packageArchitecture))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ for _, version := range []string{"", "1-", ":1.0", "1_0"} {
+ p, err := ParseControlFile(buildContent(packageName, version, packageArchitecture))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ }
+ })
+
+ t.Run("InvalidArchitecture", func(t *testing.T) {
+ p, err := ParseControlFile(buildContent(packageName, packageVersion, ""))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidArchitecture)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ content := buildContent(packageName, packageVersion, packageArchitecture)
+ full := content.String()
+
+ p, err := ParseControlFile(content)
+ require.NoError(t, err)
+ assert.NotNil(t, p)
+
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, packageArchitecture, p.Architecture)
+ assert.Equal(t, description, p.Metadata.Description)
+ assert.Equal(t, projectURL, p.Metadata.ProjectURL)
+ assert.Equal(t, packageAuthor, p.Metadata.Maintainer)
+ assert.Equal(t, []string{"a", "b"}, p.Metadata.Dependencies)
+ assert.Equal(t, full, p.Control)
+ })
+}
diff --git a/modules/packages/goproxy/metadata.go b/modules/packages/goproxy/metadata.go
new file mode 100644
index 0000000..40f7d20
--- /dev/null
+++ b/modules/packages/goproxy/metadata.go
@@ -0,0 +1,94 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package goproxy
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ PropertyGoMod = "go.mod"
+
+ maxGoModFileSize = 16 * 1024 * 1024 // https://go.dev/ref/mod#zip-path-size-constraints
+)
+
+var (
+ ErrInvalidStructure = util.NewInvalidArgumentErrorf("package has invalid structure")
+ ErrGoModFileTooLarge = util.NewInvalidArgumentErrorf("go.mod file is too large")
+)
+
+type Package struct {
+ Name string
+ Version string
+ GoMod string
+}
+
+// ParsePackage parses the Go package file
+// https://go.dev/ref/mod#zip-files
+func ParsePackage(r io.ReaderAt, size int64) (*Package, error) {
+ archive, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ var p *Package
+
+ for _, file := range archive.File {
+ nameAndVersion := path.Dir(file.Name)
+
+ parts := strings.SplitN(nameAndVersion, "@", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ versionParts := strings.SplitN(parts[1], "/", 2)
+
+ if p == nil {
+ p = &Package{
+ Name: strings.TrimSuffix(nameAndVersion, "@"+parts[1]),
+ Version: versionParts[0],
+ }
+ }
+
+ if len(versionParts) > 1 {
+ // files are expected in the "root" folder
+ continue
+ }
+
+ if path.Base(file.Name) == "go.mod" {
+ if file.UncompressedSize64 > maxGoModFileSize {
+ return nil, ErrGoModFileTooLarge
+ }
+
+ f, err := archive.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ bytes, err := io.ReadAll(&io.LimitedReader{R: f, N: maxGoModFileSize})
+ if err != nil {
+ return nil, err
+ }
+
+ p.GoMod = string(bytes)
+
+ return p, nil
+ }
+ }
+
+ if p == nil {
+ return nil, ErrInvalidStructure
+ }
+
+ p.GoMod = fmt.Sprintf("module %s", p.Name)
+
+ return p, nil
+}
diff --git a/modules/packages/goproxy/metadata_test.go b/modules/packages/goproxy/metadata_test.go
new file mode 100644
index 0000000..3a47f10
--- /dev/null
+++ b/modules/packages/goproxy/metadata_test.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package goproxy
+
+import (
+ "archive/zip"
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea.com/go-gitea/gitea"
+ packageVersion = "v0.0.1"
+)
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string][]byte) *bytes.Reader {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, _ := zw.Create(name)
+ w.Write(content)
+ }
+ zw.Close()
+ return bytes.NewReader(buf.Bytes())
+ }
+
+ t.Run("EmptyPackage", func(t *testing.T) {
+ data := createArchive(nil)
+
+ p, err := ParsePackage(data, int64(data.Len()))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidStructure)
+ })
+
+ t.Run("InvalidNameOrVersionStructure", func(t *testing.T) {
+ data := createArchive(map[string][]byte{
+ packageName + "/" + packageVersion + "/go.mod": {},
+ })
+
+ p, err := ParsePackage(data, int64(data.Len()))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidStructure)
+ })
+
+ t.Run("GoModFileInWrongDirectory", func(t *testing.T) {
+ data := createArchive(map[string][]byte{
+ packageName + "@" + packageVersion + "/subdir/go.mod": {},
+ })
+
+ p, err := ParsePackage(data, int64(data.Len()))
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, "module gitea.com/go-gitea/gitea", p.GoMod)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createArchive(map[string][]byte{
+ packageName + "@" + packageVersion + "/subdir/go.mod": []byte("invalid"),
+ packageName + "@" + packageVersion + "/go.mod": []byte("valid"),
+ })
+
+ p, err := ParsePackage(data, int64(data.Len()))
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, "valid", p.GoMod)
+ })
+}
diff --git a/modules/packages/hashed_buffer.go b/modules/packages/hashed_buffer.go
new file mode 100644
index 0000000..4ab45ed
--- /dev/null
+++ b/modules/packages/hashed_buffer.go
@@ -0,0 +1,81 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "io"
+
+ "code.gitea.io/gitea/modules/util/filebuffer"
+)
+
+// HashedSizeReader provide methods to read, sum hashes and a Size method
+type HashedSizeReader interface {
+ io.Reader
+ HashSummer
+ Size() int64
+}
+
+// HashedBuffer is buffer which calculates multiple checksums
+type HashedBuffer struct {
+ *filebuffer.FileBackedBuffer
+
+ hash *MultiHasher
+
+ combinedWriter io.Writer
+}
+
+const DefaultMemorySize = 32 * 1024 * 1024
+
+// NewHashedBuffer creates a hashed buffer with the default memory size
+func NewHashedBuffer() (*HashedBuffer, error) {
+ return NewHashedBufferWithSize(DefaultMemorySize)
+}
+
+// NewHashedBufferWithSize creates a hashed buffer with a specific memory size
+func NewHashedBufferWithSize(maxMemorySize int) (*HashedBuffer, error) {
+ b, err := filebuffer.New(maxMemorySize)
+ if err != nil {
+ return nil, err
+ }
+
+ hash := NewMultiHasher()
+
+ combinedWriter := io.MultiWriter(b, hash)
+
+ return &HashedBuffer{
+ b,
+ hash,
+ combinedWriter,
+ }, nil
+}
+
+// CreateHashedBufferFromReader creates a hashed buffer with the default memory size and copies the provided reader data into it.
+func CreateHashedBufferFromReader(r io.Reader) (*HashedBuffer, error) {
+ return CreateHashedBufferFromReaderWithSize(r, DefaultMemorySize)
+}
+
+// CreateHashedBufferFromReaderWithSize creates a hashed buffer and copies the provided reader data into it.
+func CreateHashedBufferFromReaderWithSize(r io.Reader, maxMemorySize int) (*HashedBuffer, error) {
+ b, err := NewHashedBufferWithSize(maxMemorySize)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = io.Copy(b, r)
+ if err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+// Write implements io.Writer
+func (b *HashedBuffer) Write(p []byte) (int, error) {
+ return b.combinedWriter.Write(p)
+}
+
+// Sums gets the MD5, SHA1, SHA256 and SHA512 checksums of the data
+func (b *HashedBuffer) Sums() (hashMD5, hashSHA1, hashSHA256, hashSHA512 []byte) {
+ return b.hash.Sums()
+}
diff --git a/modules/packages/hashed_buffer_test.go b/modules/packages/hashed_buffer_test.go
new file mode 100644
index 0000000..ed5267c
--- /dev/null
+++ b/modules/packages/hashed_buffer_test.go
@@ -0,0 +1,47 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "encoding/hex"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHashedBuffer(t *testing.T) {
+ cases := []struct {
+ MaxMemorySize int
+ Data string
+ HashMD5 string
+ HashSHA1 string
+ HashSHA256 string
+ HashSHA512 string
+ }{
+ {5, "test", "098f6bcd4621d373cade4e832627b4f6", "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff"},
+ {5, "testtest", "05a671c66aefea124cc08b76ea6d30bb", "51abb9636078defbf888d8457a7c76f85c8f114c", "37268335dd6931045bdcdf92623ff819a64244b53d0e746d438797349d4da578", "125d6d03b32c84d492747f79cf0bf6e179d287f341384eb5d6d3197525ad6be8e6df0116032935698f99a09e265073d1d6c32c274591bf1d0a20ad67cba921bc"},
+ }
+
+ for _, c := range cases {
+ buf, err := CreateHashedBufferFromReaderWithSize(strings.NewReader(c.Data), c.MaxMemorySize)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, len(c.Data), buf.Size())
+
+ data, err := io.ReadAll(buf)
+ require.NoError(t, err)
+ assert.Equal(t, c.Data, string(data))
+
+ hashMD5, hashSHA1, hashSHA256, hashSHA512 := buf.Sums()
+ assert.Equal(t, c.HashMD5, hex.EncodeToString(hashMD5))
+ assert.Equal(t, c.HashSHA1, hex.EncodeToString(hashSHA1))
+ assert.Equal(t, c.HashSHA256, hex.EncodeToString(hashSHA256))
+ assert.Equal(t, c.HashSHA512, hex.EncodeToString(hashSHA512))
+
+ require.NoError(t, buf.Close())
+ }
+}
diff --git a/modules/packages/helm/metadata.go b/modules/packages/helm/metadata.go
new file mode 100644
index 0000000..421fc5e
--- /dev/null
+++ b/modules/packages/helm/metadata.go
@@ -0,0 +1,130 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package helm
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ // ErrMissingChartFile indicates a missing Chart.yaml file
+ ErrMissingChartFile = util.NewInvalidArgumentErrorf("Chart.yaml file is missing")
+ // ErrInvalidName indicates an invalid package name
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ // ErrInvalidVersion indicates an invalid package version
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+ // ErrInvalidChart indicates an invalid chart
+ ErrInvalidChart = util.NewInvalidArgumentErrorf("chart is invalid")
+)
+
+// Metadata for a Chart file. This models the structure of a Chart.yaml file.
+type Metadata struct {
+ APIVersion string `json:"api_version" yaml:"apiVersion"`
+ Type string `json:"type,omitempty" yaml:"type,omitempty"`
+ Name string `json:"name" yaml:"name"`
+ Version string `json:"version" yaml:"version"`
+ AppVersion string `json:"app_version,omitempty" yaml:"appVersion,omitempty"`
+ Home string `json:"home,omitempty" yaml:"home,omitempty"`
+ Sources []string `json:"sources,omitempty" yaml:"sources,omitempty"`
+ Description string `json:"description,omitempty" yaml:"description,omitempty"`
+ Keywords []string `json:"keywords,omitempty" yaml:"keywords,omitempty"`
+ Maintainers []*Maintainer `json:"maintainers,omitempty" yaml:"maintainers,omitempty"`
+ Icon string `json:"icon,omitempty" yaml:"icon,omitempty"`
+ Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
+ Tags string `json:"tags,omitempty" yaml:"tags,omitempty"`
+ Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
+ Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
+ KubeVersion string `json:"kube_version,omitempty" yaml:"kubeVersion,omitempty"`
+ Dependencies []*Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
+}
+
+type Maintainer struct {
+ Name string `json:"name,omitempty" yaml:"name,omitempty"`
+ Email string `json:"email,omitempty" yaml:"email,omitempty"`
+ URL string `json:"url,omitempty" yaml:"url,omitempty"`
+}
+
+type Dependency struct {
+ Name string `json:"name" yaml:"name"`
+ Version string `json:"version,omitempty" yaml:"version,omitempty"`
+ Repository string `json:"repository" yaml:"repository"`
+ Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
+ Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
+ Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
+ ImportValues []any `json:"import_values,omitempty" yaml:"import-values,omitempty"`
+ Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
+}
+
+// ParseChartArchive parses the metadata of a Helm archive
+func ParseChartArchive(r io.Reader) (*Metadata, error) {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if hd.FileInfo().Name() == "Chart.yaml" {
+ if strings.Count(hd.Name, "/") != 1 {
+ continue
+ }
+
+ return ParseChartFile(tr)
+ }
+ }
+
+ return nil, ErrMissingChartFile
+}
+
+// ParseChartFile parses a Chart.yaml file to retrieve the metadata of a Helm chart
+func ParseChartFile(r io.Reader) (*Metadata, error) {
+ var metadata *Metadata
+ if err := yaml.NewDecoder(r).Decode(&metadata); err != nil {
+ return nil, err
+ }
+
+ if metadata.APIVersion == "" {
+ return nil, ErrInvalidChart
+ }
+
+ if metadata.Type != "" && metadata.Type != "application" && metadata.Type != "library" {
+ return nil, ErrInvalidChart
+ }
+
+ if metadata.Name == "" {
+ return nil, ErrInvalidName
+ }
+
+ if _, err := version.NewSemver(metadata.Version); err != nil {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(metadata.Home) {
+ metadata.Home = ""
+ }
+
+ return metadata, nil
+}
diff --git a/modules/packages/maven/metadata.go b/modules/packages/maven/metadata.go
new file mode 100644
index 0000000..42aa250
--- /dev/null
+++ b/modules/packages/maven/metadata.go
@@ -0,0 +1,93 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package maven
+
+import (
+ "encoding/xml"
+ "io"
+
+ "code.gitea.io/gitea/modules/validation"
+
+ "golang.org/x/net/html/charset"
+)
+
+// Metadata represents the metadata of a Maven package
+type Metadata struct {
+ GroupID string `json:"group_id,omitempty"`
+ ArtifactID string `json:"artifact_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Licenses []string `json:"licenses,omitempty"`
+ Dependencies []*Dependency `json:"dependencies,omitempty"`
+}
+
+// Dependency represents a dependency of a Maven package
+type Dependency struct {
+ GroupID string `json:"group_id,omitempty"`
+ ArtifactID string `json:"artifact_id,omitempty"`
+ Version string `json:"version,omitempty"`
+}
+
+type pomStruct struct {
+ XMLName xml.Name `xml:"project"`
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Name string `xml:"name"`
+ Description string `xml:"description"`
+ URL string `xml:"url"`
+ Licenses []struct {
+ Name string `xml:"name"`
+ URL string `xml:"url"`
+ Distribution string `xml:"distribution"`
+ } `xml:"licenses>license"`
+ Dependencies []struct {
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Scope string `xml:"scope"`
+ } `xml:"dependencies>dependency"`
+}
+
+// ParsePackageMetaData parses the metadata of a pom file
+func ParsePackageMetaData(r io.Reader) (*Metadata, error) {
+ var pom pomStruct
+
+ dec := xml.NewDecoder(r)
+ dec.CharsetReader = charset.NewReaderLabel
+ if err := dec.Decode(&pom); err != nil {
+ return nil, err
+ }
+
+ if !validation.IsValidURL(pom.URL) {
+ pom.URL = ""
+ }
+
+ licenses := make([]string, 0, len(pom.Licenses))
+ for _, l := range pom.Licenses {
+ if l.Name != "" {
+ licenses = append(licenses, l.Name)
+ }
+ }
+
+ dependencies := make([]*Dependency, 0, len(pom.Dependencies))
+ for _, d := range pom.Dependencies {
+ dependencies = append(dependencies, &Dependency{
+ GroupID: d.GroupID,
+ ArtifactID: d.ArtifactID,
+ Version: d.Version,
+ })
+ }
+
+ return &Metadata{
+ GroupID: pom.GroupID,
+ ArtifactID: pom.ArtifactID,
+ Name: pom.Name,
+ Description: pom.Description,
+ ProjectURL: pom.URL,
+ Licenses: licenses,
+ Dependencies: dependencies,
+ }, nil
+}
diff --git a/modules/packages/maven/metadata_test.go b/modules/packages/maven/metadata_test.go
new file mode 100644
index 0000000..d009301
--- /dev/null
+++ b/modules/packages/maven/metadata_test.go
@@ -0,0 +1,90 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package maven
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/encoding/charmap"
+)
+
+const (
+ groupID = "org.gitea"
+ artifactID = "my-project"
+ version = "1.0.1"
+ name = "My Gitea Project"
+ description = "Package Description"
+ projectURL = "https://gitea.io"
+ license = "MIT"
+ dependencyGroupID = "org.gitea.core"
+ dependencyArtifactID = "git"
+ dependencyVersion = "5.0.0"
+)
+
+const pomContent = `<?xml version="1.0"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <groupId>` + groupID + `</groupId>
+ <artifactId>` + artifactID + `</artifactId>
+ <version>` + version + `</version>
+ <name>` + name + `</name>
+ <description>` + description + `</description>
+ <url>` + projectURL + `</url>
+ <licenses>
+ <license>
+ <name>` + license + `</name>
+ </license>
+ </licenses>
+ <dependencies>
+ <dependency>
+ <groupId>` + dependencyGroupID + `</groupId>
+ <artifactId>` + dependencyArtifactID + `</artifactId>
+ <version>` + dependencyVersion + `</version>
+ </dependency>
+ </dependencies>
+</project>`
+
+func TestParsePackageMetaData(t *testing.T) {
+ t.Run("InvalidFile", func(t *testing.T) {
+ m, err := ParsePackageMetaData(strings.NewReader(""))
+ assert.Nil(t, m)
+ require.Error(t, err)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ m, err := ParsePackageMetaData(strings.NewReader(pomContent))
+ require.NoError(t, err)
+ assert.NotNil(t, m)
+
+ assert.Equal(t, groupID, m.GroupID)
+ assert.Equal(t, artifactID, m.ArtifactID)
+ assert.Equal(t, name, m.Name)
+ assert.Equal(t, description, m.Description)
+ assert.Equal(t, projectURL, m.ProjectURL)
+ assert.Len(t, m.Licenses, 1)
+ assert.Equal(t, license, m.Licenses[0])
+ assert.Len(t, m.Dependencies, 1)
+ assert.Equal(t, dependencyGroupID, m.Dependencies[0].GroupID)
+ assert.Equal(t, dependencyArtifactID, m.Dependencies[0].ArtifactID)
+ assert.Equal(t, dependencyVersion, m.Dependencies[0].Version)
+ })
+
+ t.Run("Encoding", func(t *testing.T) {
+ // UTF-8 is default but the metadata could be encoded differently
+ pomContent8859_1, err := charmap.ISO8859_1.NewEncoder().String(
+ strings.ReplaceAll(
+ pomContent,
+ `<?xml version="1.0"?>`,
+ `<?xml version="1.0" encoding="ISO-8859-1"?>`,
+ ),
+ )
+ require.NoError(t, err)
+
+ m, err := ParsePackageMetaData(strings.NewReader(pomContent8859_1))
+ require.NoError(t, err)
+ assert.NotNil(t, m)
+ })
+}
diff --git a/modules/packages/multi_hasher.go b/modules/packages/multi_hasher.go
new file mode 100644
index 0000000..83a4b5b
--- /dev/null
+++ b/modules/packages/multi_hasher.go
@@ -0,0 +1,122 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/sha512"
+ "encoding"
+ "errors"
+ "hash"
+ "io"
+)
+
+const (
+ marshaledSizeMD5 = 92
+ marshaledSizeSHA1 = 96
+ marshaledSizeSHA256 = 108
+ marshaledSizeSHA512 = 204
+
+ marshaledSize = marshaledSizeMD5 + marshaledSizeSHA1 + marshaledSizeSHA256 + marshaledSizeSHA512
+)
+
+// HashSummer provide a Sums method
+type HashSummer interface {
+ Sums() (hashMD5, hashSHA1, hashSHA256, hashSHA512 []byte)
+}
+
+// MultiHasher calculates multiple checksums
+type MultiHasher struct {
+ md5 hash.Hash
+ sha1 hash.Hash
+ sha256 hash.Hash
+ sha512 hash.Hash
+
+ combinedWriter io.Writer
+}
+
+// NewMultiHasher creates a multi hasher
+func NewMultiHasher() *MultiHasher {
+ md5 := md5.New()
+ sha1 := sha1.New()
+ sha256 := sha256.New()
+ sha512 := sha512.New()
+
+ combinedWriter := io.MultiWriter(md5, sha1, sha256, sha512)
+
+ return &MultiHasher{
+ md5,
+ sha1,
+ sha256,
+ sha512,
+ combinedWriter,
+ }
+}
+
+// MarshalBinary implements encoding.BinaryMarshaler
+func (h *MultiHasher) MarshalBinary() ([]byte, error) {
+ md5Bytes, err := h.md5.(encoding.BinaryMarshaler).MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+ sha1Bytes, err := h.sha1.(encoding.BinaryMarshaler).MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+ sha256Bytes, err := h.sha256.(encoding.BinaryMarshaler).MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+ sha512Bytes, err := h.sha512.(encoding.BinaryMarshaler).MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+
+ b := make([]byte, 0, marshaledSize)
+ b = append(b, md5Bytes...)
+ b = append(b, sha1Bytes...)
+ b = append(b, sha256Bytes...)
+ b = append(b, sha512Bytes...)
+ return b, nil
+}
+
+// UnmarshalBinary implements encoding.BinaryUnmarshaler
+func (h *MultiHasher) UnmarshalBinary(b []byte) error {
+ if len(b) != marshaledSize {
+ return errors.New("invalid hash state size")
+ }
+
+ if err := h.md5.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeMD5]); err != nil {
+ return err
+ }
+
+ b = b[marshaledSizeMD5:]
+ if err := h.sha1.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA1]); err != nil {
+ return err
+ }
+
+ b = b[marshaledSizeSHA1:]
+ if err := h.sha256.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA256]); err != nil {
+ return err
+ }
+
+ b = b[marshaledSizeSHA256:]
+ return h.sha512.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA512])
+}
+
+// Write implements io.Writer
+func (h *MultiHasher) Write(p []byte) (int, error) {
+ return h.combinedWriter.Write(p)
+}
+
+// Sums gets the MD5, SHA1, SHA256 and SHA512 checksums of the data
+func (h *MultiHasher) Sums() (hashMD5, hashSHA1, hashSHA256, hashSHA512 []byte) {
+ hashMD5 = h.md5.Sum(nil)
+ hashSHA1 = h.sha1.Sum(nil)
+ hashSHA256 = h.sha256.Sum(nil)
+ hashSHA512 = h.sha512.Sum(nil)
+ return hashMD5, hashSHA1, hashSHA256, hashSHA512
+}
diff --git a/modules/packages/multi_hasher_test.go b/modules/packages/multi_hasher_test.go
new file mode 100644
index 0000000..ca333cb
--- /dev/null
+++ b/modules/packages/multi_hasher_test.go
@@ -0,0 +1,54 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "encoding/hex"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ expectedMD5 = "e3bef03c5f3b7f6b3ab3e3053ed71e9c"
+ expectedSHA1 = "060b3b99f88e96085b4a68e095bc9e3d1d91e1bc"
+ expectedSHA256 = "6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d"
+ expectedSHA512 = "7f70e439ba8c52025c1f06cdf6ae443c4b8ed2e90059cdb9bbbf8adf80846f185a24acca9245b128b226d61753b0d7ed46580a69c8999eeff3bc13a4d0bd816c"
+)
+
+func TestMultiHasherSums(t *testing.T) {
+ t.Run("Sums", func(t *testing.T) {
+ h := NewMultiHasher()
+ h.Write([]byte("gitea"))
+
+ hashMD5, hashSHA1, hashSHA256, hashSHA512 := h.Sums()
+
+ assert.Equal(t, expectedMD5, hex.EncodeToString(hashMD5))
+ assert.Equal(t, expectedSHA1, hex.EncodeToString(hashSHA1))
+ assert.Equal(t, expectedSHA256, hex.EncodeToString(hashSHA256))
+ assert.Equal(t, expectedSHA512, hex.EncodeToString(hashSHA512))
+ })
+
+ t.Run("State", func(t *testing.T) {
+ h := NewMultiHasher()
+ h.Write([]byte("git"))
+
+ state, err := h.MarshalBinary()
+ require.NoError(t, err)
+
+ h2 := NewMultiHasher()
+ err = h2.UnmarshalBinary(state)
+ require.NoError(t, err)
+
+ h2.Write([]byte("ea"))
+
+ hashMD5, hashSHA1, hashSHA256, hashSHA512 := h2.Sums()
+
+ assert.Equal(t, expectedMD5, hex.EncodeToString(hashMD5))
+ assert.Equal(t, expectedSHA1, hex.EncodeToString(hashSHA1))
+ assert.Equal(t, expectedSHA256, hex.EncodeToString(hashSHA256))
+ assert.Equal(t, expectedSHA512, hex.EncodeToString(hashSHA512))
+ })
+}
diff --git a/modules/packages/npm/creator.go b/modules/packages/npm/creator.go
new file mode 100644
index 0000000..7d3d7cd
--- /dev/null
+++ b/modules/packages/npm/creator.go
@@ -0,0 +1,289 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package npm
+
+import (
+ "bytes"
+ "crypto/sha1"
+ "crypto/sha512"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+)
+
+var (
+ // ErrInvalidPackage indicates an invalid package
+ ErrInvalidPackage = util.NewInvalidArgumentErrorf("package is invalid")
+ // ErrInvalidPackageName indicates an invalid name
+ ErrInvalidPackageName = util.NewInvalidArgumentErrorf("package name is invalid")
+ // ErrInvalidPackageVersion indicates an invalid version
+ ErrInvalidPackageVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+ // ErrInvalidAttachment indicates a invalid attachment
+ ErrInvalidAttachment = util.NewInvalidArgumentErrorf("package attachment is invalid")
+ // ErrInvalidIntegrity indicates an integrity validation error
+ ErrInvalidIntegrity = util.NewInvalidArgumentErrorf("failed to validate integrity")
+)
+
+var nameMatch = regexp.MustCompile(`^(@[a-z0-9-][a-z0-9-._]*/)?[a-z0-9-][a-z0-9-._]*$`)
+
+// Package represents a npm package
+type Package struct {
+ Name string
+ Version string
+ DistTags []string
+ Metadata Metadata
+ Filename string
+ Data []byte
+}
+
+// PackageMetadata https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#package
+type PackageMetadata struct {
+ ID string `json:"_id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DistTags map[string]string `json:"dist-tags,omitempty"`
+ Versions map[string]*PackageMetadataVersion `json:"versions"`
+ Readme string `json:"readme,omitempty"`
+ Maintainers []User `json:"maintainers,omitempty"`
+ Time map[string]time.Time `json:"time,omitempty"`
+ Homepage string `json:"homepage,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Repository Repository `json:"repository,omitempty"`
+ Author User `json:"author"`
+ ReadmeFilename string `json:"readmeFilename,omitempty"`
+ Users map[string]bool `json:"users,omitempty"`
+ License string `json:"license,omitempty"`
+}
+
+// PackageMetadataVersion documentation: https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
+// PackageMetadataVersion response: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
+type PackageMetadataVersion struct {
+ ID string `json:"_id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Description string `json:"description"`
+ Author User `json:"author"`
+ Homepage string `json:"homepage,omitempty"`
+ License string `json:"license,omitempty"`
+ Repository Repository `json:"repository,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Dependencies map[string]string `json:"dependencies,omitempty"`
+ BundleDependencies []string `json:"bundleDependencies,omitempty"`
+ DevDependencies map[string]string `json:"devDependencies,omitempty"`
+ PeerDependencies map[string]string `json:"peerDependencies,omitempty"`
+ Bin map[string]string `json:"bin,omitempty"`
+ OptionalDependencies map[string]string `json:"optionalDependencies,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Dist PackageDistribution `json:"dist"`
+ Maintainers []User `json:"maintainers,omitempty"`
+}
+
+// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
+type PackageDistribution struct {
+ Integrity string `json:"integrity"`
+ Shasum string `json:"shasum"`
+ Tarball string `json:"tarball"`
+ FileCount int `json:"fileCount,omitempty"`
+ UnpackedSize int `json:"unpackedSize,omitempty"`
+ NpmSignature string `json:"npm-signature,omitempty"`
+}
+
+type PackageSearch struct {
+ Objects []*PackageSearchObject `json:"objects"`
+ Total int64 `json:"total"`
+}
+
+type PackageSearchObject struct {
+ Package *PackageSearchPackage `json:"package"`
+}
+
+type PackageSearchPackage struct {
+ Scope string `json:"scope"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Date time.Time `json:"date"`
+ Description string `json:"description"`
+ Author User `json:"author"`
+ Publisher User `json:"publisher"`
+ Maintainers []User `json:"maintainers"`
+ Keywords []string `json:"keywords,omitempty"`
+ Links *PackageSearchPackageLinks `json:"links"`
+}
+
+type PackageSearchPackageLinks struct {
+ Registry string `json:"npm"`
+ Homepage string `json:"homepage,omitempty"`
+ Repository string `json:"repository,omitempty"`
+}
+
+// User https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#package
+type User struct {
+ Username string `json:"username,omitempty"`
+ Name string `json:"name"`
+ Email string `json:"email,omitempty"`
+ URL string `json:"url,omitempty"`
+}
+
+// UnmarshalJSON is needed because User objects can be strings or objects
+func (u *User) UnmarshalJSON(data []byte) error {
+ switch data[0] {
+ case '"':
+ if err := json.Unmarshal(data, &u.Name); err != nil {
+ return err
+ }
+ case '{':
+ var tmp struct {
+ Username string `json:"username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ URL string `json:"url"`
+ }
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return err
+ }
+ u.Username = tmp.Username
+ u.Name = tmp.Name
+ u.Email = tmp.Email
+ u.URL = tmp.URL
+ }
+ return nil
+}
+
+// Repository https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
+type Repository struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+}
+
+// PackageAttachment https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#package
+type PackageAttachment struct {
+ ContentType string `json:"content_type"`
+ Data string `json:"data"`
+ Length int `json:"length"`
+}
+
+type packageUpload struct {
+ PackageMetadata
+ Attachments map[string]*PackageAttachment `json:"_attachments"`
+}
+
+// ParsePackage parses the content into a npm package
+func ParsePackage(r io.Reader) (*Package, error) {
+ var upload packageUpload
+ if err := json.NewDecoder(r).Decode(&upload); err != nil {
+ return nil, err
+ }
+
+ for _, meta := range upload.Versions {
+ if !validateName(meta.Name) {
+ return nil, ErrInvalidPackageName
+ }
+
+ v, err := version.NewSemver(meta.Version)
+ if err != nil {
+ return nil, ErrInvalidPackageVersion
+ }
+
+ scope := ""
+ name := meta.Name
+ nameParts := strings.SplitN(meta.Name, "/", 2)
+ if len(nameParts) == 2 {
+ scope = nameParts[0]
+ name = nameParts[1]
+ }
+
+ if !validation.IsValidURL(meta.Homepage) {
+ meta.Homepage = ""
+ }
+
+ p := &Package{
+ Name: meta.Name,
+ Version: v.String(),
+ DistTags: make([]string, 0, 1),
+ Metadata: Metadata{
+ Scope: scope,
+ Name: name,
+ Description: meta.Description,
+ Author: meta.Author.Name,
+ License: meta.License,
+ ProjectURL: meta.Homepage,
+ Keywords: meta.Keywords,
+ Dependencies: meta.Dependencies,
+ BundleDependencies: meta.BundleDependencies,
+ DevelopmentDependencies: meta.DevDependencies,
+ PeerDependencies: meta.PeerDependencies,
+ OptionalDependencies: meta.OptionalDependencies,
+ Bin: meta.Bin,
+ Readme: meta.Readme,
+ Repository: meta.Repository,
+ },
+ }
+
+ for tag := range upload.DistTags {
+ p.DistTags = append(p.DistTags, tag)
+ }
+
+ p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
+
+ attachment := func() *PackageAttachment {
+ for _, a := range upload.Attachments {
+ return a
+ }
+ return nil
+ }()
+ if attachment == nil || len(attachment.Data) == 0 {
+ return nil, ErrInvalidAttachment
+ }
+
+ data, err := base64.StdEncoding.DecodeString(attachment.Data)
+ if err != nil {
+ return nil, ErrInvalidAttachment
+ }
+ p.Data = data
+
+ integrity := strings.SplitN(meta.Dist.Integrity, "-", 2)
+ if len(integrity) != 2 {
+ return nil, ErrInvalidIntegrity
+ }
+ integrityHash, err := base64.StdEncoding.DecodeString(integrity[1])
+ if err != nil {
+ return nil, ErrInvalidIntegrity
+ }
+ var hash []byte
+ switch integrity[0] {
+ case "sha1":
+ tmp := sha1.Sum(data)
+ hash = tmp[:]
+ case "sha512":
+ tmp := sha512.Sum512(data)
+ hash = tmp[:]
+ }
+ if !bytes.Equal(integrityHash, hash) {
+ return nil, ErrInvalidIntegrity
+ }
+
+ return p, nil
+ }
+
+ return nil, ErrInvalidPackage
+}
+
+func validateName(name string) bool {
+ if strings.TrimSpace(name) != name {
+ return false
+ }
+ if len(name) == 0 || len(name) > 214 {
+ return false
+ }
+ return nameMatch.MatchString(name)
+}
diff --git a/modules/packages/npm/creator_test.go b/modules/packages/npm/creator_test.go
new file mode 100644
index 0000000..b2cf1aa
--- /dev/null
+++ b/modules/packages/npm/creator_test.go
@@ -0,0 +1,302 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package npm
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePackage(t *testing.T) {
+ packageScope := "@scope"
+ packageName := "test-package"
+ packageFullName := packageScope + "/" + packageName
+ packageVersion := "1.0.1-pre"
+ packageTag := "latest"
+ packageAuthor := "KN4CK3R"
+ packageBin := "gitea"
+ packageDescription := "Test Description"
+ data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
+ integrity := "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg=="
+ repository := Repository{
+ Type: "gitea",
+ URL: "http://localhost:3000/gitea/test.git",
+ }
+
+ t.Run("InvalidUpload", func(t *testing.T) {
+ p, err := ParsePackage(bytes.NewReader([]byte{0}))
+ assert.Nil(t, p)
+ require.Error(t, err)
+ })
+
+ t.Run("InvalidUploadNoData", func(t *testing.T) {
+ b, _ := json.Marshal(packageUpload{})
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidPackage)
+ })
+
+ t.Run("InvalidPackageName", func(t *testing.T) {
+ test := func(t *testing.T, name string) {
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: name,
+ Name: name,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: name,
+ },
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidPackageName)
+ }
+
+ test(t, " test ")
+ test(t, " test")
+ test(t, "test ")
+ test(t, "te st")
+ test(t, "Test")
+ test(t, "_test")
+ test(t, ".test")
+ test(t, "^test")
+ test(t, "te^st")
+ test(t, "te|st")
+ test(t, "te)(st")
+ test(t, "te'st")
+ test(t, "te!st")
+ test(t, "te*st")
+ test(t, "te~st")
+ test(t, "invalid/scope")
+ test(t, "@invalid/_name")
+ test(t, "@invalid/.name")
+ })
+
+ t.Run("ValidPackageName", func(t *testing.T) {
+ test := func(t *testing.T, name string) {
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: name,
+ Name: name,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: name,
+ },
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidPackageVersion)
+ }
+
+ test(t, "test")
+ test(t, "@scope/name")
+ test(t, "@scope/q")
+ test(t, "q")
+ test(t, "@scope/package-name")
+ test(t, "@scope/package.name")
+ test(t, "@scope/package_name")
+ test(t, "123name")
+ test(t, "----")
+ test(t, packageFullName)
+ })
+
+ t.Run("InvalidPackageVersion", func(t *testing.T) {
+ version := "first-version"
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ Versions: map[string]*PackageMetadataVersion{
+ version: {
+ Name: packageFullName,
+ Version: version,
+ },
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidPackageVersion)
+ })
+
+ t.Run("InvalidAttachment", func(t *testing.T) {
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: packageFullName,
+ Version: packageVersion,
+ },
+ },
+ },
+ Attachments: map[string]*PackageAttachment{
+ "dummy.tgz": {},
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidAttachment)
+ })
+
+ t.Run("InvalidData", func(t *testing.T) {
+ filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: packageFullName,
+ Version: packageVersion,
+ },
+ },
+ },
+ Attachments: map[string]*PackageAttachment{
+ filename: {
+ Data: "/",
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidAttachment)
+ })
+
+ t.Run("InvalidIntegrity", func(t *testing.T) {
+ filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: packageFullName,
+ Version: packageVersion,
+ Dist: PackageDistribution{
+ Integrity: "sha512-test==",
+ },
+ },
+ },
+ },
+ Attachments: map[string]*PackageAttachment{
+ filename: {
+ Data: data,
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidIntegrity)
+ })
+
+ t.Run("InvalidIntegrity2", func(t *testing.T) {
+ filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: packageFullName,
+ Version: packageVersion,
+ Dist: PackageDistribution{
+ Integrity: integrity,
+ },
+ },
+ },
+ },
+ Attachments: map[string]*PackageAttachment{
+ filename: {
+ Data: base64.StdEncoding.EncodeToString([]byte("data")),
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrInvalidIntegrity)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
+ b, _ := json.Marshal(packageUpload{
+ PackageMetadata: PackageMetadata{
+ ID: packageFullName,
+ Name: packageFullName,
+ DistTags: map[string]string{
+ packageTag: packageVersion,
+ },
+ Versions: map[string]*PackageMetadataVersion{
+ packageVersion: {
+ Name: packageFullName,
+ Version: packageVersion,
+ Description: packageDescription,
+ Author: User{Name: packageAuthor},
+ License: "MIT",
+ Homepage: "https://gitea.io/",
+ Readme: packageDescription,
+ Dependencies: map[string]string{
+ "package": "1.2.0",
+ },
+ Bin: map[string]string{
+ "bin": packageBin,
+ },
+ Dist: PackageDistribution{
+ Integrity: integrity,
+ },
+ Repository: repository,
+ },
+ },
+ },
+ Attachments: map[string]*PackageAttachment{
+ filename: {
+ Data: data,
+ },
+ },
+ })
+
+ p, err := ParsePackage(bytes.NewReader(b))
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageFullName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.Equal(t, []string{packageTag}, p.DistTags)
+ assert.Equal(t, fmt.Sprintf("%s-%s.tgz", strings.Split(packageFullName, "/")[1], packageVersion), p.Filename)
+ b, _ = base64.StdEncoding.DecodeString(data)
+ assert.Equal(t, b, p.Data)
+ assert.Equal(t, packageName, p.Metadata.Name)
+ assert.Equal(t, packageScope, p.Metadata.Scope)
+ assert.Equal(t, packageDescription, p.Metadata.Description)
+ assert.Equal(t, packageDescription, p.Metadata.Readme)
+ assert.Equal(t, packageAuthor, p.Metadata.Author)
+ assert.Equal(t, packageBin, p.Metadata.Bin["bin"])
+ assert.Equal(t, "MIT", p.Metadata.License)
+ assert.Equal(t, "https://gitea.io/", p.Metadata.ProjectURL)
+ assert.Contains(t, p.Metadata.Dependencies, "package")
+ assert.Equal(t, "1.2.0", p.Metadata.Dependencies["package"])
+ assert.Equal(t, repository.Type, p.Metadata.Repository.Type)
+ assert.Equal(t, repository.URL, p.Metadata.Repository.URL)
+ })
+}
diff --git a/modules/packages/npm/metadata.go b/modules/packages/npm/metadata.go
new file mode 100644
index 0000000..6bb77f3
--- /dev/null
+++ b/modules/packages/npm/metadata.go
@@ -0,0 +1,26 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package npm
+
+// TagProperty is the name of the property for tag management
+const TagProperty = "npm.tag"
+
+// Metadata represents the metadata of a npm package
+type Metadata struct {
+ Scope string `json:"scope,omitempty"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ Author string `json:"author,omitempty"`
+ License string `json:"license,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Dependencies map[string]string `json:"dependencies,omitempty"`
+ BundleDependencies []string `json:"bundleDependencies,omitempty"`
+ DevelopmentDependencies map[string]string `json:"development_dependencies,omitempty"`
+ PeerDependencies map[string]string `json:"peer_dependencies,omitempty"`
+ OptionalDependencies map[string]string `json:"optional_dependencies,omitempty"`
+ Bin map[string]string `json:"bin,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Repository Repository `json:"repository,omitempty"`
+}
diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go
new file mode 100644
index 0000000..1e98ddf
--- /dev/null
+++ b/modules/packages/nuget/metadata.go
@@ -0,0 +1,239 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+)
+
+var (
+ // ErrMissingNuspecFile indicates a missing Nuspec file
+ ErrMissingNuspecFile = util.NewInvalidArgumentErrorf("Nuspec file is missing")
+ // ErrNuspecFileTooLarge indicates a Nuspec file which is too large
+ ErrNuspecFileTooLarge = util.NewInvalidArgumentErrorf("Nuspec file is too large")
+ // ErrNuspecInvalidID indicates an invalid id in the Nuspec file
+ ErrNuspecInvalidID = util.NewInvalidArgumentErrorf("Nuspec file contains an invalid id")
+ // ErrNuspecInvalidVersion indicates an invalid version in the Nuspec file
+ ErrNuspecInvalidVersion = util.NewInvalidArgumentErrorf("Nuspec file contains an invalid version")
+)
+
+// PackageType specifies the package type the metadata describes
+type PackageType int
+
+const (
+ // DependencyPackage represents a package (*.nupkg)
+ DependencyPackage PackageType = iota + 1
+ // SymbolsPackage represents a symbol package (*.snupkg)
+ SymbolsPackage
+
+ PropertySymbolID = "nuget.symbol.id"
+)
+
+var idmatch = regexp.MustCompile(`\A\w+(?:[.-]\w+)*\z`)
+
+const maxNuspecFileSize = 3 * 1024 * 1024
+
+// Package represents a Nuget package
+type Package struct {
+ PackageType PackageType
+ ID string
+ Version string
+ Metadata *Metadata
+ NuspecContent *bytes.Buffer
+}
+
+// Metadata represents the metadata of a Nuget package
+type Metadata struct {
+ Description string `json:"description,omitempty"`
+ ReleaseNotes string `json:"release_notes,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Authors string `json:"authors,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ RequireLicenseAcceptance bool `json:"require_license_acceptance"`
+ Dependencies map[string][]Dependency `json:"dependencies,omitempty"`
+}
+
+// Dependency represents a dependency of a Nuget package
+type Dependency struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+}
+
+// https://learn.microsoft.com/en-us/nuget/reference/nuspec
+type nuspecPackage struct {
+ Metadata struct {
+ ID string `xml:"id"`
+ Version string `xml:"version"`
+ Authors string `xml:"authors"`
+ RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance"`
+ ProjectURL string `xml:"projectUrl"`
+ Description string `xml:"description"`
+ ReleaseNotes string `xml:"releaseNotes"`
+ Readme string `xml:"readme"`
+ PackageTypes struct {
+ PackageType []struct {
+ Name string `xml:"name,attr"`
+ } `xml:"packageType"`
+ } `xml:"packageTypes"`
+ Repository struct {
+ URL string `xml:"url,attr"`
+ } `xml:"repository"`
+ Dependencies struct {
+ Dependency []struct {
+ ID string `xml:"id,attr"`
+ Version string `xml:"version,attr"`
+ Exclude string `xml:"exclude,attr"`
+ } `xml:"dependency"`
+ Group []struct {
+ TargetFramework string `xml:"targetFramework,attr"`
+ Dependency []struct {
+ ID string `xml:"id,attr"`
+ Version string `xml:"version,attr"`
+ Exclude string `xml:"exclude,attr"`
+ } `xml:"dependency"`
+ } `xml:"group"`
+ } `xml:"dependencies"`
+ } `xml:"metadata"`
+}
+
+// ParsePackageMetaData parses the metadata of a Nuget package file
+func ParsePackageMetaData(r io.ReaderAt, size int64) (*Package, error) {
+ archive, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, file := range archive.File {
+ if filepath.Dir(file.Name) != "." {
+ continue
+ }
+ if strings.HasSuffix(strings.ToLower(file.Name), ".nuspec") {
+ if file.UncompressedSize64 > maxNuspecFileSize {
+ return nil, ErrNuspecFileTooLarge
+ }
+ f, err := archive.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ return ParseNuspecMetaData(archive, f)
+ }
+ }
+ return nil, ErrMissingNuspecFile
+}
+
+// ParseNuspecMetaData parses a Nuspec file to retrieve the metadata of a Nuget package
+func ParseNuspecMetaData(archive *zip.Reader, r io.Reader) (*Package, error) {
+ var nuspecBuf bytes.Buffer
+ var p nuspecPackage
+ if err := xml.NewDecoder(io.TeeReader(r, &nuspecBuf)).Decode(&p); err != nil {
+ return nil, err
+ }
+
+ if !idmatch.MatchString(p.Metadata.ID) {
+ return nil, ErrNuspecInvalidID
+ }
+
+ v, err := version.NewSemver(p.Metadata.Version)
+ if err != nil {
+ return nil, ErrNuspecInvalidVersion
+ }
+
+ if !validation.IsValidURL(p.Metadata.ProjectURL) {
+ p.Metadata.ProjectURL = ""
+ }
+
+ packageType := DependencyPackage
+ for _, pt := range p.Metadata.PackageTypes.PackageType {
+ if pt.Name == "SymbolsPackage" {
+ packageType = SymbolsPackage
+ break
+ }
+ }
+
+ m := &Metadata{
+ Description: p.Metadata.Description,
+ ReleaseNotes: p.Metadata.ReleaseNotes,
+ Authors: p.Metadata.Authors,
+ ProjectURL: p.Metadata.ProjectURL,
+ RepositoryURL: p.Metadata.Repository.URL,
+ RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance,
+ Dependencies: make(map[string][]Dependency),
+ }
+
+ if p.Metadata.Readme != "" {
+ f, err := archive.Open(p.Metadata.Readme)
+ if err == nil {
+ buf, _ := io.ReadAll(f)
+ m.Readme = string(buf)
+ _ = f.Close()
+ }
+ }
+
+ if len(p.Metadata.Dependencies.Dependency) > 0 {
+ deps := make([]Dependency, 0, len(p.Metadata.Dependencies.Dependency))
+ for _, dep := range p.Metadata.Dependencies.Dependency {
+ if dep.ID == "" || dep.Version == "" {
+ continue
+ }
+ deps = append(deps, Dependency{
+ ID: dep.ID,
+ Version: dep.Version,
+ })
+ }
+ m.Dependencies[""] = deps
+ }
+ for _, group := range p.Metadata.Dependencies.Group {
+ deps := make([]Dependency, 0, len(group.Dependency))
+ for _, dep := range group.Dependency {
+ if dep.ID == "" || dep.Version == "" {
+ continue
+ }
+ deps = append(deps, Dependency{
+ ID: dep.ID,
+ Version: dep.Version,
+ })
+ }
+ if len(deps) > 0 {
+ m.Dependencies[group.TargetFramework] = deps
+ }
+ }
+ return &Package{
+ PackageType: packageType,
+ ID: p.Metadata.ID,
+ Version: toNormalizedVersion(v),
+ Metadata: m,
+ NuspecContent: &nuspecBuf,
+ }, nil
+}
+
+// https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#normalized-version-numbers
+// https://github.com/NuGet/NuGet.Client/blob/dccbd304b11103e08b97abf4cf4bcc1499d9235a/src/NuGet.Core/NuGet.Versioning/VersionFormatter.cs#L121
+func toNormalizedVersion(v *version.Version) string {
+ var buf bytes.Buffer
+ segments := v.Segments64()
+ fmt.Fprintf(&buf, "%d.%d.%d", segments[0], segments[1], segments[2])
+ if len(segments) > 3 && segments[3] > 0 {
+ fmt.Fprintf(&buf, ".%d", segments[3])
+ }
+ pre := v.Prerelease()
+ if pre != "" {
+ fmt.Fprint(&buf, "-", pre)
+ }
+ return buf.String()
+}
diff --git a/modules/packages/nuget/metadata_test.go b/modules/packages/nuget/metadata_test.go
new file mode 100644
index 0000000..ecce052
--- /dev/null
+++ b/modules/packages/nuget/metadata_test.go
@@ -0,0 +1,188 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "archive/zip"
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ id = "System.Gitea"
+ semver = "1.0.1"
+ authors = "Gitea Authors"
+ projectURL = "https://gitea.io"
+ description = "Package Description"
+ releaseNotes = "Package Release Notes"
+ readme = "Readme"
+ repositoryURL = "https://gitea.io/gitea/gitea"
+ targetFramework = ".NETStandard2.1"
+ dependencyID = "System.Text.Json"
+ dependencyVersion = "5.0.0"
+)
+
+const nuspecContent = `<?xml version="1.0" encoding="utf-8"?>
+<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ <version>` + semver + `</version>
+ <authors>` + authors + `</authors>
+ <requireLicenseAcceptance>true</requireLicenseAcceptance>
+ <projectUrl>` + projectURL + `</projectUrl>
+ <description>` + description + `</description>
+ <releaseNotes>` + releaseNotes + `</releaseNotes>
+ <repository url="` + repositoryURL + `" />
+ <readme>README.md</readme>
+ <dependencies>
+ <group targetFramework="` + targetFramework + `">
+ <dependency id="` + dependencyID + `" version="` + dependencyVersion + `" exclude="Build,Analyzers" />
+ </group>
+ </dependencies>
+ </metadata>
+</package>`
+
+const symbolsNuspecContent = `<?xml version="1.0" encoding="utf-8"?>
+<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ <version>` + semver + `</version>
+ <description>` + description + `</description>
+ <packageTypes>
+ <packageType name="SymbolsPackage" />
+ </packageTypes>
+ <dependencies>
+ <group targetFramework="` + targetFramework + `" />
+ </dependencies>
+ </metadata>
+</package>`
+
+func TestParsePackageMetaData(t *testing.T) {
+ createArchive := func(files map[string]string) []byte {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, _ := archive.Create(name)
+ w.Write([]byte(content))
+ }
+ archive.Close()
+ return buf.Bytes()
+ }
+
+ t.Run("MissingNuspecFile", func(t *testing.T) {
+ data := createArchive(map[string]string{"dummy.txt": ""})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, np)
+ require.ErrorIs(t, err, ErrMissingNuspecFile)
+ })
+
+ t.Run("MissingNuspecFileInRoot", func(t *testing.T) {
+ data := createArchive(map[string]string{"sub/package.nuspec": ""})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, np)
+ require.ErrorIs(t, err, ErrMissingNuspecFile)
+ })
+
+ t.Run("InvalidNuspecFile", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": ""})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, np)
+ require.Error(t, err)
+ })
+
+ t.Run("InvalidPackageId", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": `<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata></metadata>
+ </package>`})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, np)
+ require.ErrorIs(t, err, ErrNuspecInvalidID)
+ })
+
+ t.Run("InvalidPackageVersion", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": `<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ </metadata>
+ </package>`})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ assert.Nil(t, np)
+ require.ErrorIs(t, err, ErrNuspecInvalidVersion)
+ })
+
+ t.Run("MissingReadme", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": nuspecContent})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, np)
+ assert.Empty(t, np.Metadata.Readme)
+ })
+
+ t.Run("Dependency Package", func(t *testing.T) {
+ data := createArchive(map[string]string{
+ "package.nuspec": nuspecContent,
+ "README.md": readme,
+ })
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, np)
+ assert.Equal(t, DependencyPackage, np.PackageType)
+
+ assert.Equal(t, id, np.ID)
+ assert.Equal(t, semver, np.Version)
+ assert.Equal(t, authors, np.Metadata.Authors)
+ assert.Equal(t, projectURL, np.Metadata.ProjectURL)
+ assert.Equal(t, description, np.Metadata.Description)
+ assert.Equal(t, releaseNotes, np.Metadata.ReleaseNotes)
+ assert.Equal(t, readme, np.Metadata.Readme)
+ assert.Equal(t, repositoryURL, np.Metadata.RepositoryURL)
+ assert.Len(t, np.Metadata.Dependencies, 1)
+ assert.Contains(t, np.Metadata.Dependencies, targetFramework)
+ deps := np.Metadata.Dependencies[targetFramework]
+ assert.Len(t, deps, 1)
+ assert.Equal(t, dependencyID, deps[0].ID)
+ assert.Equal(t, dependencyVersion, deps[0].Version)
+
+ t.Run("NormalizedVersion", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": `<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>test</id>
+ <version>1.04.5.2.5-rc.1+metadata</version>
+ </metadata>
+ </package>`})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, np)
+ assert.Equal(t, "1.4.5.2-rc.1", np.Version)
+ })
+ })
+
+ t.Run("Symbols Package", func(t *testing.T) {
+ data := createArchive(map[string]string{"package.nuspec": symbolsNuspecContent})
+
+ np, err := ParsePackageMetaData(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.NotNil(t, np)
+ assert.Equal(t, SymbolsPackage, np.PackageType)
+
+ assert.Equal(t, id, np.ID)
+ assert.Equal(t, semver, np.Version)
+ assert.Equal(t, description, np.Metadata.Description)
+ assert.Empty(t, np.Metadata.Dependencies)
+ })
+}
diff --git a/modules/packages/nuget/symbol_extractor.go b/modules/packages/nuget/symbol_extractor.go
new file mode 100644
index 0000000..81bf037
--- /dev/null
+++ b/modules/packages/nuget/symbol_extractor.go
@@ -0,0 +1,186 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/packages"
+ "code.gitea.io/gitea/modules/util"
+)
+
+var (
+ ErrMissingPdbFiles = util.NewInvalidArgumentErrorf("package does not contain PDB files")
+ ErrInvalidFiles = util.NewInvalidArgumentErrorf("package contains invalid files")
+ ErrInvalidPdbMagicNumber = util.NewInvalidArgumentErrorf("invalid Portable PDB magic number")
+ ErrMissingPdbStream = util.NewInvalidArgumentErrorf("missing PDB stream")
+)
+
+type PortablePdb struct {
+ Name string
+ ID string
+ Content *packages.HashedBuffer
+}
+
+type PortablePdbList []*PortablePdb
+
+func (l PortablePdbList) Close() {
+ for _, pdb := range l {
+ pdb.Content.Close()
+ }
+}
+
+// ExtractPortablePdb extracts PDB files from a .snupkg file
+func ExtractPortablePdb(r io.ReaderAt, size int64) (PortablePdbList, error) {
+ archive, err := zip.NewReader(r, size)
+ if err != nil {
+ return nil, err
+ }
+
+ var pdbs PortablePdbList
+
+ err = func() error {
+ for _, file := range archive.File {
+ if strings.HasSuffix(file.Name, "/") {
+ continue
+ }
+ ext := strings.ToLower(filepath.Ext(file.Name))
+
+ switch ext {
+ case ".nuspec", ".xml", ".psmdcp", ".rels", ".p7s":
+ continue
+ case ".pdb":
+ f, err := archive.Open(file.Name)
+ if err != nil {
+ return err
+ }
+
+ buf, err := packages.CreateHashedBufferFromReader(f)
+
+ f.Close()
+
+ if err != nil {
+ return err
+ }
+
+ id, err := ParseDebugHeaderID(buf)
+ if err != nil {
+ buf.Close()
+ return fmt.Errorf("Invalid PDB file: %w", err)
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ buf.Close()
+ return err
+ }
+
+ pdbs = append(pdbs, &PortablePdb{
+ Name: path.Base(file.Name),
+ ID: id,
+ Content: buf,
+ })
+ default:
+ return ErrInvalidFiles
+ }
+ }
+ return nil
+ }()
+ if err != nil {
+ pdbs.Close()
+ return nil, err
+ }
+
+ if len(pdbs) == 0 {
+ return nil, ErrMissingPdbFiles
+ }
+
+ return pdbs, nil
+}
+
+// ParseDebugHeaderID TODO
+func ParseDebugHeaderID(r io.ReadSeeker) (string, error) {
+ var magic uint32
+ if err := binary.Read(r, binary.LittleEndian, &magic); err != nil {
+ return "", err
+ }
+ if magic != 0x424A5342 {
+ return "", ErrInvalidPdbMagicNumber
+ }
+
+ if _, err := r.Seek(8, io.SeekCurrent); err != nil {
+ return "", err
+ }
+
+ var versionStringSize int32
+ if err := binary.Read(r, binary.LittleEndian, &versionStringSize); err != nil {
+ return "", err
+ }
+ if _, err := r.Seek(int64(versionStringSize), io.SeekCurrent); err != nil {
+ return "", err
+ }
+ if _, err := r.Seek(2, io.SeekCurrent); err != nil {
+ return "", err
+ }
+
+ var streamCount int16
+ if err := binary.Read(r, binary.LittleEndian, &streamCount); err != nil {
+ return "", err
+ }
+
+ read4ByteAlignedString := func(r io.Reader) (string, error) {
+ b := make([]byte, 4)
+ var buf bytes.Buffer
+ for {
+ if _, err := r.Read(b); err != nil {
+ return "", err
+ }
+ if i := bytes.IndexByte(b, 0); i != -1 {
+ buf.Write(b[:i])
+ return buf.String(), nil
+ }
+ buf.Write(b)
+ }
+ }
+
+ for i := 0; i < int(streamCount); i++ {
+ var offset uint32
+ if err := binary.Read(r, binary.LittleEndian, &offset); err != nil {
+ return "", err
+ }
+ if _, err := r.Seek(4, io.SeekCurrent); err != nil {
+ return "", err
+ }
+ name, err := read4ByteAlignedString(r)
+ if err != nil {
+ return "", err
+ }
+
+ if name == "#Pdb" {
+ if _, err := r.Seek(int64(offset), io.SeekStart); err != nil {
+ return "", err
+ }
+
+ b := make([]byte, 16)
+ if _, err := r.Read(b); err != nil {
+ return "", err
+ }
+
+ data1 := binary.LittleEndian.Uint32(b[0:4])
+ data2 := binary.LittleEndian.Uint16(b[4:6])
+ data3 := binary.LittleEndian.Uint16(b[6:8])
+ data4 := b[8:16]
+
+ return fmt.Sprintf("%08x%04x%04x%04x%012x", data1, data2, data3, data4[:2], data4[2:]), nil
+ }
+ }
+
+ return "", ErrMissingPdbStream
+}
diff --git a/modules/packages/nuget/symbol_extractor_test.go b/modules/packages/nuget/symbol_extractor_test.go
new file mode 100644
index 0000000..b767ed0
--- /dev/null
+++ b/modules/packages/nuget/symbol_extractor_test.go
@@ -0,0 +1,82 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const pdbContent = `QlNKQgEAAQAAAAAADAAAAFBEQiB2MS4wAAAAAAAABgB8AAAAWAAAACNQZGIAAAAA1AAAAAgBAAAj
+fgAA3AEAAAQAAAAjU3RyaW5ncwAAAADgAQAABAAAACNVUwDkAQAAMAAAACNHVUlEAAAAFAIAACgB
+AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`
+
+func TestExtractPortablePdb(t *testing.T) {
+ createArchive := func(name string, content []byte) []byte {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create(name)
+ w.Write(content)
+ archive.Close()
+ return buf.Bytes()
+ }
+
+ t.Run("MissingPdbFiles", func(t *testing.T) {
+ var buf bytes.Buffer
+ zip.NewWriter(&buf).Close()
+
+ pdbs, err := ExtractPortablePdb(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ require.ErrorIs(t, err, ErrMissingPdbFiles)
+ assert.Empty(t, pdbs)
+ })
+
+ t.Run("InvalidFiles", func(t *testing.T) {
+ data := createArchive("sub/test.bin", []byte{})
+
+ pdbs, err := ExtractPortablePdb(bytes.NewReader(data), int64(len(data)))
+ require.ErrorIs(t, err, ErrInvalidFiles)
+ assert.Empty(t, pdbs)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ b, _ := base64.StdEncoding.DecodeString(pdbContent)
+ data := createArchive("test.pdb", b)
+
+ pdbs, err := ExtractPortablePdb(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+ assert.Len(t, pdbs, 1)
+ assert.Equal(t, "test.pdb", pdbs[0].Name)
+ assert.Equal(t, "d910bb6948bd4c6cb40155bcf52c3c94", pdbs[0].ID)
+ pdbs.Close()
+ })
+}
+
+func TestParseDebugHeaderID(t *testing.T) {
+ t.Run("InvalidPdbMagicNumber", func(t *testing.T) {
+ id, err := ParseDebugHeaderID(bytes.NewReader([]byte{0, 0, 0, 0}))
+ require.ErrorIs(t, err, ErrInvalidPdbMagicNumber)
+ assert.Empty(t, id)
+ })
+
+ t.Run("MissingPdbStream", func(t *testing.T) {
+ b, _ := base64.StdEncoding.DecodeString(`QlNKQgEAAQAAAAAADAAAAFBEQiB2MS4wAAAAAAAAAQB8AAAAWAAAACNVUwA=`)
+
+ id, err := ParseDebugHeaderID(bytes.NewReader(b))
+ require.ErrorIs(t, err, ErrMissingPdbStream)
+ assert.Empty(t, id)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ b, _ := base64.StdEncoding.DecodeString(pdbContent)
+
+ id, err := ParseDebugHeaderID(bytes.NewReader(b))
+ require.NoError(t, err)
+ assert.Equal(t, "d910bb6948bd4c6cb40155bcf52c3c94", id)
+ })
+}
diff --git a/modules/packages/pub/metadata.go b/modules/packages/pub/metadata.go
new file mode 100644
index 0000000..afb464e
--- /dev/null
+++ b/modules/packages/pub/metadata.go
@@ -0,0 +1,153 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pub
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrMissingPubspecFile = util.NewInvalidArgumentErrorf("Pubspec file is missing")
+ ErrPubspecFileTooLarge = util.NewInvalidArgumentErrorf("Pubspec file is too large")
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+)
+
+var namePattern = regexp.MustCompile(`\A[a-zA-Z_][a-zA-Z0-9_]*\z`)
+
+// https://github.com/dart-lang/pub-dev/blob/4d582302a8d10152a5cd6129f65bf4f4dbca239d/pkg/pub_package_reader/lib/pub_package_reader.dart#L143
+const maxPubspecFileSize = 128 * 1024
+
+// Package represents a Pub package
+type Package struct {
+ Name string
+ Version string
+ Metadata *Metadata
+}
+
+// Metadata represents the metadata of a Pub package
+type Metadata struct {
+ Description string `json:"description,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ DocumentationURL string `json:"documentation_url,omitempty"`
+ Readme string `json:"readme,omitempty"`
+ Pubspec any `json:"pubspec"`
+}
+
+type pubspecPackage struct {
+ Name string `yaml:"name"`
+ Version string `yaml:"version"`
+ Description string `yaml:"description"`
+ Homepage string `yaml:"homepage"`
+ Repository string `yaml:"repository"`
+ Documentation string `yaml:"documentation"`
+}
+
+// ParsePackage parses the Pub package file
+func ParsePackage(r io.Reader) (*Package, error) {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ var p *Package
+ var readme string
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if hd.Name == "pubspec.yaml" {
+ if hd.Size > maxPubspecFileSize {
+ return nil, ErrPubspecFileTooLarge
+ }
+ p, err = ParsePubspecMetadata(tr)
+ if err != nil {
+ return nil, err
+ }
+ } else if strings.ToLower(hd.Name) == "readme.md" {
+ data, err := io.ReadAll(tr)
+ if err != nil {
+ return nil, err
+ }
+ readme = string(data)
+ }
+ }
+
+ if p == nil {
+ return nil, ErrMissingPubspecFile
+ }
+
+ p.Metadata.Readme = readme
+
+ return p, nil
+}
+
+// ParsePubspecMetadata parses a Pubspec file to retrieve the metadata of a Pub package
+func ParsePubspecMetadata(r io.Reader) (*Package, error) {
+ buf, err := io.ReadAll(io.LimitReader(r, maxPubspecFileSize))
+ if err != nil {
+ return nil, err
+ }
+
+ var p pubspecPackage
+ if err := yaml.Unmarshal(buf, &p); err != nil {
+ return nil, err
+ }
+
+ if !namePattern.MatchString(p.Name) {
+ return nil, ErrInvalidName
+ }
+
+ v, err := version.NewSemver(p.Version)
+ if err != nil {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(p.Homepage) {
+ p.Homepage = ""
+ }
+ if !validation.IsValidURL(p.Repository) {
+ p.Repository = ""
+ }
+
+ var pubspec any
+ if err := yaml.Unmarshal(buf, &pubspec); err != nil {
+ return nil, err
+ }
+
+ return &Package{
+ Name: p.Name,
+ Version: v.String(),
+ Metadata: &Metadata{
+ Description: p.Description,
+ ProjectURL: p.Homepage,
+ RepositoryURL: p.Repository,
+ DocumentationURL: p.Documentation,
+ Pubspec: pubspec,
+ },
+ }, nil
+}
diff --git a/modules/packages/pub/metadata_test.go b/modules/packages/pub/metadata_test.go
new file mode 100644
index 0000000..5ed083b
--- /dev/null
+++ b/modules/packages/pub/metadata_test.go
@@ -0,0 +1,136 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pub
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ description = "Package Description"
+ projectURL = "https://gitea.com"
+ repositoryURL = "https://gitea.com/gitea/gitea"
+ documentationURL = "https://docs.gitea.com"
+)
+
+const pubspecContent = `name: ` + packageName + `
+version: ` + packageVersion + `
+description: ` + description + `
+homepage: ` + projectURL + `
+repository: ` + repositoryURL + `
+documentation: ` + documentationURL + `
+
+environment:
+ sdk: '>=2.16.0 <3.0.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ path: '>=1.8.0 <3.0.0'
+
+dev_dependencies:
+ http: '>=0.13.0'`
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string][]byte) io.Reader {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(zw)
+ for filename, content := range files {
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ }
+ tw.Close()
+ zw.Close()
+ return &buf
+ }
+
+ t.Run("MissingPubspecFile", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"dummy.txt": {}})
+
+ pp, err := ParsePackage(data)
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrMissingPubspecFile)
+ })
+
+ t.Run("PubspecFileTooLarge", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"pubspec.yaml": make([]byte, 200*1024)})
+
+ pp, err := ParsePackage(data)
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrPubspecFileTooLarge)
+ })
+
+ t.Run("InvalidPubspecFile", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"pubspec.yaml": {}})
+
+ pp, err := ParsePackage(data)
+ assert.Nil(t, pp)
+ require.Error(t, err)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"pubspec.yaml": []byte(pubspecContent)})
+
+ pp, err := ParsePackage(data)
+ require.NoError(t, err)
+ assert.NotNil(t, pp)
+ assert.Empty(t, pp.Metadata.Readme)
+ })
+
+ t.Run("ValidWithReadme", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"pubspec.yaml": []byte(pubspecContent), "README.md": []byte("readme")})
+
+ pp, err := ParsePackage(data)
+ require.NoError(t, err)
+ assert.NotNil(t, pp)
+ assert.Equal(t, "readme", pp.Metadata.Readme)
+ })
+}
+
+func TestParsePubspecMetadata(t *testing.T) {
+ t.Run("InvalidName", func(t *testing.T) {
+ for _, name := range []string{"123abc", "ab-cd"} {
+ pp, err := ParsePubspecMetadata(strings.NewReader(`name: ` + name))
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrInvalidName)
+ }
+ })
+
+ t.Run("InvalidVersion", func(t *testing.T) {
+ pp, err := ParsePubspecMetadata(strings.NewReader(`name: dummy
+version: invalid`))
+ assert.Nil(t, pp)
+ require.ErrorIs(t, err, ErrInvalidVersion)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ pp, err := ParsePubspecMetadata(strings.NewReader(pubspecContent))
+ require.NoError(t, err)
+ assert.NotNil(t, pp)
+
+ assert.Equal(t, packageName, pp.Name)
+ assert.Equal(t, packageVersion, pp.Version)
+ assert.Equal(t, description, pp.Metadata.Description)
+ assert.Equal(t, projectURL, pp.Metadata.ProjectURL)
+ assert.Equal(t, repositoryURL, pp.Metadata.RepositoryURL)
+ assert.Equal(t, documentationURL, pp.Metadata.DocumentationURL)
+ assert.NotNil(t, pp.Metadata.Pubspec)
+ })
+}
diff --git a/modules/packages/pypi/metadata.go b/modules/packages/pypi/metadata.go
new file mode 100644
index 0000000..125728c
--- /dev/null
+++ b/modules/packages/pypi/metadata.go
@@ -0,0 +1,15 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pypi
+
+// Metadata represents the metadata of a PyPI package
+type Metadata struct {
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ LongDescription string `json:"long_description,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ License string `json:"license,omitempty"`
+ RequiresPython string `json:"requires_python,omitempty"`
+}
diff --git a/modules/packages/rpm/metadata.go b/modules/packages/rpm/metadata.go
new file mode 100644
index 0000000..f4f78c2
--- /dev/null
+++ b/modules/packages/rpm/metadata.go
@@ -0,0 +1,298 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rpm
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/sassoftware/go-rpmutils"
+)
+
+const (
+ PropertyMetadata = "rpm.metadata"
+ PropertyGroup = "rpm.group"
+ PropertyArchitecture = "rpm.architecture"
+
+ SettingKeyPrivate = "rpm.key.private"
+ SettingKeyPublic = "rpm.key.public"
+
+ RepositoryPackage = "_rpm"
+ RepositoryVersion = "_repository"
+)
+
+const (
+ // Can't use the syscall constants because they are not available for windows build.
+ sIFMT = 0xf000
+ sIFDIR = 0x4000
+ sIXUSR = 0x40
+ sIXGRP = 0x8
+ sIXOTH = 0x1
+)
+
+// https://rpm-software-management.github.io/rpm/manual/spec.html
+// https://refspecs.linuxbase.org/LSB_3.1.0/LSB-Core-generic/LSB-Core-generic/pkgformat.html
+
+type Package struct {
+ Name string
+ Version string
+ VersionMetadata *VersionMetadata
+ FileMetadata *FileMetadata
+}
+
+type VersionMetadata struct {
+ License string `json:"license,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+type FileMetadata struct {
+ Architecture string `json:"architecture,omitempty"`
+ Epoch string `json:"epoch,omitempty"`
+ Version string `json:"version,omitempty"`
+ Release string `json:"release,omitempty"`
+ Vendor string `json:"vendor,omitempty"`
+ Group string `json:"group,omitempty"`
+ Packager string `json:"packager,omitempty"`
+ SourceRpm string `json:"source_rpm,omitempty"`
+ BuildHost string `json:"build_host,omitempty"`
+ BuildTime uint64 `json:"build_time,omitempty"`
+ FileTime uint64 `json:"file_time,omitempty"`
+ InstalledSize uint64 `json:"installed_size,omitempty"`
+ ArchiveSize uint64 `json:"archive_size,omitempty"`
+
+ Provides []*Entry `json:"provide,omitempty"`
+ Requires []*Entry `json:"require,omitempty"`
+ Conflicts []*Entry `json:"conflict,omitempty"`
+ Obsoletes []*Entry `json:"obsolete,omitempty"`
+
+ Files []*File `json:"files,omitempty"`
+
+ Changelogs []*Changelog `json:"changelogs,omitempty"`
+}
+
+type Entry struct {
+ Name string `json:"name" xml:"name,attr"`
+ Flags string `json:"flags,omitempty" xml:"flags,attr,omitempty"`
+ Version string `json:"version,omitempty" xml:"ver,attr,omitempty"`
+ Epoch string `json:"epoch,omitempty" xml:"epoch,attr,omitempty"`
+ Release string `json:"release,omitempty" xml:"rel,attr,omitempty"`
+}
+
+type File struct {
+ Path string `json:"path" xml:",chardata"`
+ Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
+ IsExecutable bool `json:"is_executable" xml:"-"`
+}
+
+type Changelog struct {
+ Author string `json:"author,omitempty" xml:"author,attr"`
+ Date timeutil.TimeStamp `json:"date,omitempty" xml:"date,attr"`
+ Text string `json:"text,omitempty" xml:",chardata"`
+}
+
+// ParsePackage parses the RPM package file
+func ParsePackage(r io.Reader) (*Package, error) {
+ rpm, err := rpmutils.ReadRpm(r)
+ if err != nil {
+ return nil, err
+ }
+
+ nevra, err := rpm.Header.GetNEVRA()
+ if err != nil {
+ return nil, err
+ }
+
+ version := fmt.Sprintf("%s-%s", nevra.Version, nevra.Release)
+ if nevra.Epoch != "" && nevra.Epoch != "0" {
+ version = fmt.Sprintf("%s-%s", nevra.Epoch, version)
+ }
+
+ p := &Package{
+ Name: nevra.Name,
+ Version: version,
+ VersionMetadata: &VersionMetadata{
+ Summary: getString(rpm.Header, rpmutils.SUMMARY),
+ Description: getString(rpm.Header, rpmutils.DESCRIPTION),
+ License: getString(rpm.Header, rpmutils.LICENSE),
+ ProjectURL: getString(rpm.Header, rpmutils.URL),
+ },
+ FileMetadata: &FileMetadata{
+ Architecture: nevra.Arch,
+ Epoch: nevra.Epoch,
+ Version: nevra.Version,
+ Release: nevra.Release,
+ Vendor: getString(rpm.Header, rpmutils.VENDOR),
+ Group: getString(rpm.Header, rpmutils.GROUP),
+ Packager: getString(rpm.Header, rpmutils.PACKAGER),
+ SourceRpm: getString(rpm.Header, rpmutils.SOURCERPM),
+ BuildHost: getString(rpm.Header, rpmutils.BUILDHOST),
+ BuildTime: getUInt64(rpm.Header, rpmutils.BUILDTIME),
+ FileTime: getUInt64(rpm.Header, rpmutils.FILEMTIMES),
+ InstalledSize: getUInt64(rpm.Header, rpmutils.SIZE),
+ ArchiveSize: getUInt64(rpm.Header, rpmutils.SIG_PAYLOADSIZE),
+
+ Provides: getEntries(rpm.Header, rpmutils.PROVIDENAME, rpmutils.PROVIDEVERSION, rpmutils.PROVIDEFLAGS),
+ Requires: getEntries(rpm.Header, rpmutils.REQUIRENAME, rpmutils.REQUIREVERSION, rpmutils.REQUIREFLAGS),
+ Conflicts: getEntries(rpm.Header, rpmutils.CONFLICTNAME, rpmutils.CONFLICTVERSION, rpmutils.CONFLICTFLAGS),
+ Obsoletes: getEntries(rpm.Header, rpmutils.OBSOLETENAME, rpmutils.OBSOLETEVERSION, rpmutils.OBSOLETEFLAGS),
+ Files: getFiles(rpm.Header),
+ Changelogs: getChangelogs(rpm.Header),
+ },
+ }
+
+ if !validation.IsValidURL(p.VersionMetadata.ProjectURL) {
+ p.VersionMetadata.ProjectURL = ""
+ }
+
+ return p, nil
+}
+
+func getString(h *rpmutils.RpmHeader, tag int) string {
+ values, err := h.GetStrings(tag)
+ if err != nil || len(values) < 1 {
+ return ""
+ }
+ return values[0]
+}
+
+func getUInt64(h *rpmutils.RpmHeader, tag int) uint64 {
+ values, err := h.GetUint64s(tag)
+ if err != nil || len(values) < 1 {
+ return 0
+ }
+ return values[0]
+}
+
+func getEntries(h *rpmutils.RpmHeader, namesTag, versionsTag, flagsTag int) []*Entry {
+ names, err := h.GetStrings(namesTag)
+ if err != nil || len(names) == 0 {
+ return nil
+ }
+ flags, err := h.GetUint64s(flagsTag)
+ if err != nil || len(flags) == 0 {
+ return nil
+ }
+ versions, err := h.GetStrings(versionsTag)
+ if err != nil || len(versions) == 0 {
+ return nil
+ }
+ if len(names) != len(flags) || len(names) != len(versions) {
+ return nil
+ }
+
+ entries := make([]*Entry, 0, len(names))
+ for i := range names {
+ e := &Entry{
+ Name: names[i],
+ }
+
+ flags := flags[i]
+ if (flags&rpmutils.RPMSENSE_GREATER) != 0 && (flags&rpmutils.RPMSENSE_EQUAL) != 0 {
+ e.Flags = "GE"
+ } else if (flags&rpmutils.RPMSENSE_LESS) != 0 && (flags&rpmutils.RPMSENSE_EQUAL) != 0 {
+ e.Flags = "LE"
+ } else if (flags & rpmutils.RPMSENSE_GREATER) != 0 {
+ e.Flags = "GT"
+ } else if (flags & rpmutils.RPMSENSE_LESS) != 0 {
+ e.Flags = "LT"
+ } else if (flags & rpmutils.RPMSENSE_EQUAL) != 0 {
+ e.Flags = "EQ"
+ }
+
+ version := versions[i]
+ if version != "" {
+ parts := strings.Split(version, "-")
+
+ versionParts := strings.Split(parts[0], ":")
+ if len(versionParts) == 2 {
+ e.Version = versionParts[1]
+ e.Epoch = versionParts[0]
+ } else {
+ e.Version = versionParts[0]
+ e.Epoch = "0"
+ }
+
+ if len(parts) > 1 {
+ e.Release = parts[1]
+ }
+ }
+
+ entries = append(entries, e)
+ }
+ return entries
+}
+
+func getFiles(h *rpmutils.RpmHeader) []*File {
+ baseNames, _ := h.GetStrings(rpmutils.BASENAMES)
+ dirNames, _ := h.GetStrings(rpmutils.DIRNAMES)
+ dirIndexes, _ := h.GetUint32s(rpmutils.DIRINDEXES)
+ fileFlags, _ := h.GetUint32s(rpmutils.FILEFLAGS)
+ fileModes, _ := h.GetUint32s(rpmutils.FILEMODES)
+
+ files := make([]*File, 0, len(baseNames))
+ for i := range baseNames {
+ if len(dirIndexes) <= i {
+ continue
+ }
+ dirIndex := dirIndexes[i]
+ if len(dirNames) <= int(dirIndex) {
+ continue
+ }
+
+ var fileType string
+ var isExecutable bool
+ if i < len(fileFlags) && (fileFlags[i]&rpmutils.RPMFILE_GHOST) != 0 {
+ fileType = "ghost"
+ } else if i < len(fileModes) {
+ if (fileModes[i] & sIFMT) == sIFDIR {
+ fileType = "dir"
+ } else {
+ mode := fileModes[i] & ^uint32(sIFMT)
+ isExecutable = (mode&sIXUSR) != 0 || (mode&sIXGRP) != 0 || (mode&sIXOTH) != 0
+ }
+ }
+
+ files = append(files, &File{
+ Path: dirNames[dirIndex] + baseNames[i],
+ Type: fileType,
+ IsExecutable: isExecutable,
+ })
+ }
+
+ return files
+}
+
+func getChangelogs(h *rpmutils.RpmHeader) []*Changelog {
+ texts, err := h.GetStrings(rpmutils.CHANGELOGTEXT)
+ if err != nil || len(texts) == 0 {
+ return nil
+ }
+ authors, err := h.GetStrings(rpmutils.CHANGELOGNAME)
+ if err != nil || len(authors) == 0 {
+ return nil
+ }
+ times, err := h.GetUint32s(rpmutils.CHANGELOGTIME)
+ if err != nil || len(times) == 0 {
+ return nil
+ }
+ if len(texts) != len(authors) || len(texts) != len(times) {
+ return nil
+ }
+
+ changelogs := make([]*Changelog, 0, len(texts))
+ for i := range texts {
+ changelogs = append(changelogs, &Changelog{
+ Author: authors[i],
+ Date: timeutil.TimeStamp(times[i]),
+ Text: texts[i],
+ })
+ }
+ return changelogs
+}
diff --git a/modules/packages/rpm/metadata_test.go b/modules/packages/rpm/metadata_test.go
new file mode 100644
index 0000000..dc9b480
--- /dev/null
+++ b/modules/packages/rpm/metadata_test.go
@@ -0,0 +1,164 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rpm
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePackage(t *testing.T) {
+ base64RpmPackageContent := `H4sICFayB2QCAGdpdGVhLXRlc3QtMS4wLjItMS14ODZfNjQucnBtAO2YV4gTQRjHJzl7wbNhhxVF
+VNwk2zd2PdvZ9Sxnd3Z3NllNsmF3o6congVFsWFHRWwIImIXfRER0QcRfPBJEXvvBQvWSfZTT0VQ
+8TF/MuU33zcz3+zOJGEe73lyuQBRBWKWRzDrEddjuVAkxLMc+lsFUOWfm5bvvReAalWECg/TsivU
+dyKa0U61aVnl6wj0Uxe4nc8F92hZiaYE8CO/P0r7/Quegr0c7M/AvoCaGZEIWNGUqMHrhhGROIUT
+Zc7gOAOraoQzCNZ0WdU0HpEI5jiB4zlek3gT85wqCBomhomxoGCs8wImWMImbxqKgXVNUKKaqShR
+STKVKK9glFUNcf2g+/t27xs16v5x/eyOKftVGlIhyiuvvPLKK6+88sorr7zyyiuvvPKCO5HPnz+v
+pGVhhXsTsFVeSstuWR9anwU+Bk3Vch5wTwL3JkHg+8C1gR8A169wj1KdpobAj4HbAT+Be5VewE+h
+fz/g52AvBX4N9vHAb4AnA7+F8ePAH8BuA38ELgf+BLzQ50oIeBlw0OdAOXAlP57AGuCsbwGtbgCu
+DrwRuAb4bwau6T/PwFbgWsDXgWuD/y3gOmC/B1wI/Bi4AcT3Arih3z9YCNzI9w9m/YKUG4Nd9N9z
+pSZgHwrcFPgccFt//OADGE+F/q+Ao+D/FrijzwV1gbv4/QvaAHcFDgF3B5aB+wB3Be7rz1dQCtwP
+eDxwMcw3GbgU7AasdwzYE8DjwT4L/CeAvRx4IvBCYA3iWQds+FzpDjABfghsAj8BTgA/A/b8+StX
+A84A1wKe5s9fuRB4JpzHZv55rL8a/Dv49vpn/PErR4BvQX8Z+Db4l2W5CH2/f0W5+1fEoeFDBzFp
+rE/FMcK4mWQSOzN+aDOIqztW2rPsFKIyqh7sQERR42RVMSKihnzVHlQ8Ag0YLBYNEIajkhmuR5Io
+7nlpt2M4nJs0ZNkoYaUyZahMlSfJImr1n1WjFVNCPCaTZgYNGdGL8YN2mX8WHfA/C7ViHJK0pxHG
+SrkeTiSI4T+7ubf85yrzRCQRQ5EVxVAjvIBVRY/KRFAVReIkhfARSddNSceayQkGliIKb0q8RAxJ
+5QWNVxHIsW3Pz369bw+5jh5y0klE9Znqm0dF57b0HbGy2A5lVUBTZZrqZjdUjYoprFmpsBtHP5d0
++ISltS2yk2mHuC4x+lgJMhgnidvuqy3b0suK0bm+tw3FMxI2zjm7/fA0MtQhplX2s7nYLZ2ZC0yg
+CxJZDokhORTJlrlcCvG5OieGBERlVCs7CfuS6WzQ/T2j+9f92BWxTFEcp2IkYccYGp2LYySEfreq
+irue4WRF5XkpKovw2wgpq2rZBI8bQZkzxEkiYaNwxnXCCVvHidzIiB3CM2yMYdNWmjDsaLovaE4c
+x3a6mLaTxB7rEj3jWN4M2p7uwPaa1GfI8BHFfcZMKhkycnhR7y781/a+A4t7FpWWTupRUtKbegwZ
+XMKwJinTSe70uhRcj55qNu3YHtE922Fdz7FTMTq9Q3TbMdiYrrPudMvT44S6u2miu138eC0tTN9D
+2CFGHHtQsHHsGCRFDFbXuT9wx6mUTZfseydlkWZeJkW6xOgYjqXT+LA7I6XHaUx2xmUzqelWymA9
+rCXI9+D1BHbjsITssqhBNysw0tOWjcpmIh6+aViYPfftw8ZSGfRVPUqKiosZj5R5qGmk/8AjjRbZ
+d8b3vvngdPHx3HvMeCarIk7VVSwbgoZVkceEVyOmyUmGxBGNYDVKSFSOGlIkGqWnUZFkiY/wsmhK
+Mu0UFYgZ/bYnuvn/vz4wtCz8qMwsHUvP0PX3tbYFUctAPdrY6tiiDtcCddDECahx7SuVNP5dpmb5
+9tMDyaXb7OAlk5acuPn57ss9mw6Wym0m1Fq2cej7tUt2LL4/b8enXU2fndk+fvv57ndnt55/cQob
+7tpp/pEjDS7cGPZ6BY430+7danDq6f42Nw49b9F7zp6BiKpJb9s5P0AYN2+L159cnrur636rx+v1
+7ae1K28QbMMcqI8CqwIrgwg9nTOp8Oj9q81plUY7ZuwXN8Vvs8wbAAA=`
+ rpmPackageContent, err := base64.StdEncoding.DecodeString(base64RpmPackageContent)
+ require.NoError(t, err)
+
+ zr, err := gzip.NewReader(bytes.NewReader(rpmPackageContent))
+ require.NoError(t, err)
+
+ p, err := ParsePackage(zr)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "gitea-test", p.Name)
+ assert.Equal(t, "1.0.2-1", p.Version)
+ assert.NotNil(t, p.VersionMetadata)
+ assert.NotNil(t, p.FileMetadata)
+
+ assert.Equal(t, "MIT", p.VersionMetadata.License)
+ assert.Equal(t, "https://gitea.io", p.VersionMetadata.ProjectURL)
+ assert.Equal(t, "RPM package summary", p.VersionMetadata.Summary)
+ assert.Equal(t, "RPM package description", p.VersionMetadata.Description)
+
+ assert.Equal(t, "x86_64", p.FileMetadata.Architecture)
+ assert.Equal(t, "0", p.FileMetadata.Epoch)
+ assert.Equal(t, "1.0.2", p.FileMetadata.Version)
+ assert.Equal(t, "1", p.FileMetadata.Release)
+ assert.Empty(t, p.FileMetadata.Vendor)
+ assert.Equal(t, "KN4CK3R", p.FileMetadata.Packager)
+ assert.Equal(t, "gitea-test-1.0.2-1.src.rpm", p.FileMetadata.SourceRpm)
+ assert.Equal(t, "e44b1687d04b", p.FileMetadata.BuildHost)
+ assert.EqualValues(t, 1678225964, p.FileMetadata.BuildTime)
+ assert.EqualValues(t, 1678225964, p.FileMetadata.FileTime)
+ assert.EqualValues(t, 13, p.FileMetadata.InstalledSize)
+ assert.EqualValues(t, 272, p.FileMetadata.ArchiveSize)
+ assert.Empty(t, p.FileMetadata.Conflicts)
+ assert.Empty(t, p.FileMetadata.Obsoletes)
+
+ assert.ElementsMatch(
+ t,
+ []*Entry{
+ {
+ Name: "gitea-test",
+ Flags: "EQ",
+ Version: "1.0.2",
+ Epoch: "0",
+ Release: "1",
+ },
+ {
+ Name: "gitea-test(x86-64)",
+ Flags: "EQ",
+ Version: "1.0.2",
+ Epoch: "0",
+ Release: "1",
+ },
+ },
+ p.FileMetadata.Provides,
+ )
+ assert.ElementsMatch(
+ t,
+ []*Entry{
+ {
+ Name: "/bin/sh",
+ },
+ {
+ Name: "/bin/sh",
+ },
+ {
+ Name: "/bin/sh",
+ },
+ {
+ Name: "rpmlib(CompressedFileNames)",
+ Flags: "LE",
+ Version: "3.0.4",
+ Epoch: "0",
+ Release: "1",
+ },
+ {
+ Name: "rpmlib(FileDigests)",
+ Flags: "LE",
+ Version: "4.6.0",
+ Epoch: "0",
+ Release: "1",
+ },
+ {
+ Name: "rpmlib(PayloadFilesHavePrefix)",
+ Flags: "LE",
+ Version: "4.0",
+ Epoch: "0",
+ Release: "1",
+ },
+ {
+ Name: "rpmlib(PayloadIsXz)",
+ Flags: "LE",
+ Version: "5.2",
+ Epoch: "0",
+ Release: "1",
+ },
+ },
+ p.FileMetadata.Requires,
+ )
+ assert.ElementsMatch(
+ t,
+ []*File{
+ {
+ Path: "/usr/local/bin/hello",
+ IsExecutable: true,
+ },
+ },
+ p.FileMetadata.Files,
+ )
+ assert.ElementsMatch(
+ t,
+ []*Changelog{
+ {
+ Author: "KN4CK3R <dummy@gitea.io>",
+ Date: 1678276800,
+ Text: "- Changelog message.",
+ },
+ },
+ p.FileMetadata.Changelogs,
+ )
+}
diff --git a/modules/packages/rubygems/marshal.go b/modules/packages/rubygems/marshal.go
new file mode 100644
index 0000000..4e6a5fc
--- /dev/null
+++ b/modules/packages/rubygems/marshal.go
@@ -0,0 +1,311 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rubygems
+
+import (
+ "bufio"
+ "bytes"
+ "io"
+ "reflect"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ majorVersion = 4
+ minorVersion = 8
+
+ typeNil = '0'
+ typeTrue = 'T'
+ typeFalse = 'F'
+ typeFixnum = 'i'
+ typeString = '"'
+ typeSymbol = ':'
+ typeSymbolLink = ';'
+ typeArray = '['
+ typeIVar = 'I'
+ typeUserMarshal = 'U'
+ typeUserDef = 'u'
+ typeObject = 'o'
+)
+
+var (
+ // ErrUnsupportedType indicates an unsupported type
+ ErrUnsupportedType = util.NewInvalidArgumentErrorf("type is unsupported")
+ // ErrInvalidIntRange indicates an invalid number range
+ ErrInvalidIntRange = util.NewInvalidArgumentErrorf("number is not in valid range")
+)
+
+// RubyUserMarshal is a Ruby object that has a marshal_load function.
+type RubyUserMarshal struct {
+ Name string
+ Value any
+}
+
+// RubyUserDef is a Ruby object that has a _load function.
+type RubyUserDef struct {
+ Name string
+ Value any
+}
+
+// RubyObject is a default Ruby object.
+type RubyObject struct {
+ Name string
+ Member map[string]any
+}
+
+// MarshalEncoder mimics Rubys Marshal class.
+// Note: Only supports types used by the RubyGems package registry.
+type MarshalEncoder struct {
+ w *bufio.Writer
+ symbols map[string]int
+}
+
+// NewMarshalEncoder creates a new MarshalEncoder
+func NewMarshalEncoder(w io.Writer) *MarshalEncoder {
+ return &MarshalEncoder{
+ w: bufio.NewWriter(w),
+ symbols: map[string]int{},
+ }
+}
+
+// Encode encodes the given type
+func (e *MarshalEncoder) Encode(v any) error {
+ if _, err := e.w.Write([]byte{majorVersion, minorVersion}); err != nil {
+ return err
+ }
+
+ if err := e.marshal(v); err != nil {
+ return err
+ }
+
+ return e.w.Flush()
+}
+
+func (e *MarshalEncoder) marshal(v any) error {
+ if v == nil {
+ return e.marshalNil()
+ }
+
+ val := reflect.ValueOf(v)
+ typ := reflect.TypeOf(v)
+
+ if typ.Kind() == reflect.Ptr {
+ val = val.Elem()
+ typ = typ.Elem()
+ }
+
+ switch typ.Kind() {
+ case reflect.Bool:
+ return e.marshalBool(val.Bool())
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32:
+ return e.marshalInt(val.Int())
+ case reflect.String:
+ return e.marshalString(val.String())
+ case reflect.Slice, reflect.Array:
+ return e.marshalArray(val)
+ }
+
+ switch typ.Name() {
+ case "RubyUserMarshal":
+ return e.marshalUserMarshal(val.Interface().(RubyUserMarshal))
+ case "RubyUserDef":
+ return e.marshalUserDef(val.Interface().(RubyUserDef))
+ case "RubyObject":
+ return e.marshalObject(val.Interface().(RubyObject))
+ }
+
+ return ErrUnsupportedType
+}
+
+func (e *MarshalEncoder) marshalNil() error {
+ return e.w.WriteByte(typeNil)
+}
+
+func (e *MarshalEncoder) marshalBool(b bool) error {
+ if b {
+ return e.w.WriteByte(typeTrue)
+ }
+ return e.w.WriteByte(typeFalse)
+}
+
+func (e *MarshalEncoder) marshalInt(i int64) error {
+ if err := e.w.WriteByte(typeFixnum); err != nil {
+ return err
+ }
+
+ return e.marshalIntInternal(i)
+}
+
+func (e *MarshalEncoder) marshalIntInternal(i int64) error {
+ if i == 0 {
+ return e.w.WriteByte(0)
+ } else if 0 < i && i < 123 {
+ return e.w.WriteByte(byte(i + 5))
+ } else if -124 < i && i <= -1 {
+ return e.w.WriteByte(byte(i - 5))
+ }
+
+ var length int
+ if 122 < i && i <= 0xff {
+ length = 1
+ } else if 0xff < i && i <= 0xffff {
+ length = 2
+ } else if 0xffff < i && i <= 0xffffff {
+ length = 3
+ } else if 0xffffff < i && i <= 0x3fffffff {
+ length = 4
+ } else if -0x100 <= i && i < -123 {
+ length = -1
+ } else if -0x10000 <= i && i < -0x100 {
+ length = -2
+ } else if -0x1000000 <= i && i < -0x100000 {
+ length = -3
+ } else if -0x40000000 <= i && i < -0x1000000 {
+ length = -4
+ } else {
+ return ErrInvalidIntRange
+ }
+
+ if err := e.w.WriteByte(byte(length)); err != nil {
+ return err
+ }
+ if length < 0 {
+ length = -length
+ }
+
+ for c := 0; c < length; c++ {
+ if err := e.w.WriteByte(byte(i >> uint(8*c) & 0xff)); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (e *MarshalEncoder) marshalString(str string) error {
+ if err := e.w.WriteByte(typeIVar); err != nil {
+ return err
+ }
+
+ if err := e.marshalRawString(str); err != nil {
+ return err
+ }
+
+ if err := e.marshalIntInternal(1); err != nil {
+ return err
+ }
+
+ if err := e.marshalSymbol("E"); err != nil {
+ return err
+ }
+
+ return e.marshalBool(true)
+}
+
+func (e *MarshalEncoder) marshalRawString(str string) error {
+ if err := e.w.WriteByte(typeString); err != nil {
+ return err
+ }
+
+ if err := e.marshalIntInternal(int64(len(str))); err != nil {
+ return err
+ }
+
+ _, err := e.w.WriteString(str)
+ return err
+}
+
+func (e *MarshalEncoder) marshalSymbol(str string) error {
+ if index, ok := e.symbols[str]; ok {
+ if err := e.w.WriteByte(typeSymbolLink); err != nil {
+ return err
+ }
+ return e.marshalIntInternal(int64(index))
+ }
+
+ e.symbols[str] = len(e.symbols)
+
+ if err := e.w.WriteByte(typeSymbol); err != nil {
+ return err
+ }
+
+ if err := e.marshalIntInternal(int64(len(str))); err != nil {
+ return err
+ }
+
+ _, err := e.w.WriteString(str)
+ return err
+}
+
+func (e *MarshalEncoder) marshalArray(arr reflect.Value) error {
+ if err := e.w.WriteByte(typeArray); err != nil {
+ return err
+ }
+
+ length := arr.Len()
+
+ if err := e.marshalIntInternal(int64(length)); err != nil {
+ return err
+ }
+
+ for i := 0; i < length; i++ {
+ if err := e.marshal(arr.Index(i).Interface()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *MarshalEncoder) marshalUserMarshal(userMarshal RubyUserMarshal) error {
+ if err := e.w.WriteByte(typeUserMarshal); err != nil {
+ return err
+ }
+
+ if err := e.marshalSymbol(userMarshal.Name); err != nil {
+ return err
+ }
+
+ return e.marshal(userMarshal.Value)
+}
+
+func (e *MarshalEncoder) marshalUserDef(userDef RubyUserDef) error {
+ var buf bytes.Buffer
+ if err := NewMarshalEncoder(&buf).Encode(userDef.Value); err != nil {
+ return err
+ }
+
+ if err := e.w.WriteByte(typeUserDef); err != nil {
+ return err
+ }
+ if err := e.marshalSymbol(userDef.Name); err != nil {
+ return err
+ }
+ if err := e.marshalIntInternal(int64(buf.Len())); err != nil {
+ return err
+ }
+ _, err := e.w.Write(buf.Bytes())
+ return err
+}
+
+func (e *MarshalEncoder) marshalObject(obj RubyObject) error {
+ if err := e.w.WriteByte(typeObject); err != nil {
+ return err
+ }
+ if err := e.marshalSymbol(obj.Name); err != nil {
+ return err
+ }
+ if err := e.marshalIntInternal(int64(len(obj.Member))); err != nil {
+ return err
+ }
+ for k, v := range obj.Member {
+ if err := e.marshalSymbol(k); err != nil {
+ return err
+ }
+ if err := e.marshal(v); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/modules/packages/rubygems/marshal_test.go b/modules/packages/rubygems/marshal_test.go
new file mode 100644
index 0000000..8aa9160
--- /dev/null
+++ b/modules/packages/rubygems/marshal_test.go
@@ -0,0 +1,99 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rubygems
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMinimalEncoder(t *testing.T) {
+ cases := []struct {
+ Value any
+ Expected []byte
+ Error error
+ }{
+ {
+ Value: nil,
+ Expected: []byte{4, 8, 0x30},
+ },
+ {
+ Value: true,
+ Expected: []byte{4, 8, 'T'},
+ },
+ {
+ Value: false,
+ Expected: []byte{4, 8, 'F'},
+ },
+ {
+ Value: 0,
+ Expected: []byte{4, 8, 'i', 0},
+ },
+ {
+ Value: 1,
+ Expected: []byte{4, 8, 'i', 6},
+ },
+ {
+ Value: -1,
+ Expected: []byte{4, 8, 'i', 0xfa},
+ },
+ {
+ Value: 0x1fffffff,
+ Expected: []byte{4, 8, 'i', 4, 0xff, 0xff, 0xff, 0x1f},
+ },
+ {
+ Value: 0x41000000,
+ Error: ErrInvalidIntRange,
+ },
+ {
+ Value: "test",
+ Expected: []byte{4, 8, 'I', '"', 9, 't', 'e', 's', 't', 6, ':', 6, 'E', 'T'},
+ },
+ {
+ Value: []int{1, 2},
+ Expected: []byte{4, 8, '[', 7, 'i', 6, 'i', 7},
+ },
+ {
+ Value: &RubyUserMarshal{
+ Name: "Test",
+ Value: 4,
+ },
+ Expected: []byte{4, 8, 'U', ':', 9, 'T', 'e', 's', 't', 'i', 9},
+ },
+ {
+ Value: &RubyUserDef{
+ Name: "Test",
+ Value: 4,
+ },
+ Expected: []byte{4, 8, 'u', ':', 9, 'T', 'e', 's', 't', 9, 4, 8, 'i', 9},
+ },
+ {
+ Value: &RubyObject{
+ Name: "Test",
+ Member: map[string]any{
+ "test": 4,
+ },
+ },
+ Expected: []byte{4, 8, 'o', ':', 9, 'T', 'e', 's', 't', 6, ':', 9, 't', 'e', 's', 't', 'i', 9},
+ },
+ {
+ Value: &struct {
+ Name string
+ }{
+ "test",
+ },
+ Error: ErrUnsupportedType,
+ },
+ }
+
+ for i, c := range cases {
+ var b bytes.Buffer
+ err := NewMarshalEncoder(&b).Encode(c.Value)
+ require.ErrorIs(t, err, c.Error)
+ assert.Equal(t, c.Expected, b.Bytes(), "case %d", i)
+ }
+}
diff --git a/modules/packages/rubygems/metadata.go b/modules/packages/rubygems/metadata.go
new file mode 100644
index 0000000..8a97948
--- /dev/null
+++ b/modules/packages/rubygems/metadata.go
@@ -0,0 +1,220 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rubygems
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ // ErrMissingMetadataFile indicates a missing metadata.gz file
+ ErrMissingMetadataFile = util.NewInvalidArgumentErrorf("metadata.gz file is missing")
+ // ErrInvalidName indicates an invalid id in the metadata.gz file
+ ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
+ // ErrInvalidVersion indicates an invalid version in the metadata.gz file
+ ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
+)
+
+var versionMatcher = regexp.MustCompile(`\A[0-9]+(?:\.[0-9a-zA-Z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z`)
+
+// Package represents a RubyGems package
+type Package struct {
+ Name string
+ Version string
+ Metadata *Metadata
+}
+
+// Metadata represents the metadata of a RubyGems package
+type Metadata struct {
+ Platform string `json:"platform,omitempty"`
+ Description string `json:"description,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Authors []string `json:"authors,omitempty"`
+ Licenses []string `json:"licenses,omitempty"`
+ RequiredRubyVersion []VersionRequirement `json:"required_ruby_version,omitempty"`
+ RequiredRubygemsVersion []VersionRequirement `json:"required_rubygems_version,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RuntimeDependencies []Dependency `json:"runtime_dependencies,omitempty"`
+ DevelopmentDependencies []Dependency `json:"development_dependencies,omitempty"`
+}
+
+// VersionRequirement represents a version restriction
+type VersionRequirement struct {
+ Restriction string `json:"restriction"`
+ Version string `json:"version"`
+}
+
+// Dependency represents a dependency of a RubyGems package
+type Dependency struct {
+ Name string `json:"name"`
+ Version []VersionRequirement `json:"version"`
+}
+
+type gemspec struct {
+ Name string `yaml:"name"`
+ Version struct {
+ Version string `yaml:"version"`
+ } `yaml:"version"`
+ Platform string `yaml:"platform"`
+ Authors []string `yaml:"authors"`
+ Autorequire any `yaml:"autorequire"`
+ Bindir string `yaml:"bindir"`
+ CertChain []any `yaml:"cert_chain"`
+ Date string `yaml:"date"`
+ Dependencies []struct {
+ Name string `yaml:"name"`
+ Requirement requirement `yaml:"requirement"`
+ Type string `yaml:"type"`
+ Prerelease bool `yaml:"prerelease"`
+ VersionRequirements requirement `yaml:"version_requirements"`
+ } `yaml:"dependencies"`
+ Description string `yaml:"description"`
+ Executables []string `yaml:"executables"`
+ Extensions []any `yaml:"extensions"`
+ ExtraRdocFiles []string `yaml:"extra_rdoc_files"`
+ Files []string `yaml:"files"`
+ Homepage string `yaml:"homepage"`
+ Licenses []string `yaml:"licenses"`
+ Metadata struct {
+ BugTrackerURI string `yaml:"bug_tracker_uri"`
+ ChangelogURI string `yaml:"changelog_uri"`
+ DocumentationURI string `yaml:"documentation_uri"`
+ SourceCodeURI string `yaml:"source_code_uri"`
+ } `yaml:"metadata"`
+ PostInstallMessage any `yaml:"post_install_message"`
+ RdocOptions []any `yaml:"rdoc_options"`
+ RequirePaths []string `yaml:"require_paths"`
+ RequiredRubyVersion requirement `yaml:"required_ruby_version"`
+ RequiredRubygemsVersion requirement `yaml:"required_rubygems_version"`
+ Requirements []any `yaml:"requirements"`
+ RubygemsVersion string `yaml:"rubygems_version"`
+ SigningKey any `yaml:"signing_key"`
+ SpecificationVersion int `yaml:"specification_version"`
+ Summary string `yaml:"summary"`
+ TestFiles []any `yaml:"test_files"`
+}
+
+type requirement struct {
+ Requirements [][]any `yaml:"requirements"`
+}
+
+// AsVersionRequirement converts into []VersionRequirement
+func (r requirement) AsVersionRequirement() []VersionRequirement {
+ requirements := make([]VersionRequirement, 0, len(r.Requirements))
+ for _, req := range r.Requirements {
+ if len(req) != 2 {
+ continue
+ }
+ restriction, ok := req[0].(string)
+ if !ok {
+ continue
+ }
+ vm, ok := req[1].(map[string]any)
+ if !ok {
+ continue
+ }
+ versionInt, ok := vm["version"]
+ if !ok {
+ continue
+ }
+ version, ok := versionInt.(string)
+ if !ok || version == "0" {
+ continue
+ }
+
+ requirements = append(requirements, VersionRequirement{
+ Restriction: restriction,
+ Version: version,
+ })
+ }
+ return requirements
+}
+
+// ParsePackageMetaData parses the metadata of a Gem package file
+func ParsePackageMetaData(r io.Reader) (*Package, error) {
+ archive := tar.NewReader(r)
+ for {
+ hdr, err := archive.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hdr.Name == "metadata.gz" {
+ return parseMetadataFile(archive)
+ }
+ }
+
+ return nil, ErrMissingMetadataFile
+}
+
+func parseMetadataFile(r io.Reader) (*Package, error) {
+ zr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer zr.Close()
+
+ var spec gemspec
+ if err := yaml.NewDecoder(zr).Decode(&spec); err != nil {
+ return nil, err
+ }
+
+ if len(spec.Name) == 0 || strings.Contains(spec.Name, "/") {
+ return nil, ErrInvalidName
+ }
+
+ if !versionMatcher.MatchString(spec.Version.Version) {
+ return nil, ErrInvalidVersion
+ }
+
+ if !validation.IsValidURL(spec.Homepage) {
+ spec.Homepage = ""
+ }
+ if !validation.IsValidURL(spec.Metadata.SourceCodeURI) {
+ spec.Metadata.SourceCodeURI = ""
+ }
+
+ m := &Metadata{
+ Platform: spec.Platform,
+ Description: spec.Description,
+ Summary: spec.Summary,
+ Authors: spec.Authors,
+ Licenses: spec.Licenses,
+ ProjectURL: spec.Homepage,
+ RequiredRubyVersion: spec.RequiredRubyVersion.AsVersionRequirement(),
+ RequiredRubygemsVersion: spec.RequiredRubygemsVersion.AsVersionRequirement(),
+ DevelopmentDependencies: make([]Dependency, 0, 5),
+ RuntimeDependencies: make([]Dependency, 0, 5),
+ }
+
+ for _, gemdep := range spec.Dependencies {
+ dep := Dependency{
+ Name: gemdep.Name,
+ Version: gemdep.Requirement.AsVersionRequirement(),
+ }
+ if gemdep.Type == ":runtime" {
+ m.RuntimeDependencies = append(m.RuntimeDependencies, dep)
+ } else {
+ m.DevelopmentDependencies = append(m.DevelopmentDependencies, dep)
+ }
+ }
+
+ return &Package{
+ Name: spec.Name,
+ Version: spec.Version.Version,
+ Metadata: m,
+ }, nil
+}
diff --git a/modules/packages/rubygems/metadata_test.go b/modules/packages/rubygems/metadata_test.go
new file mode 100644
index 0000000..cd3a5bb
--- /dev/null
+++ b/modules/packages/rubygems/metadata_test.go
@@ -0,0 +1,89 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rubygems
+
+import (
+ "archive/tar"
+ "bytes"
+ "encoding/base64"
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePackageMetaData(t *testing.T) {
+ createArchive := func(filename string, content []byte) io.Reader {
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ tw.Close()
+ return &buf
+ }
+
+ t.Run("MissingMetadataFile", func(t *testing.T) {
+ data := createArchive("dummy.txt", []byte{0})
+
+ rp, err := ParsePackageMetaData(data)
+ require.ErrorIs(t, err, ErrMissingMetadataFile)
+ assert.Nil(t, rp)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ content, _ := base64.StdEncoding.DecodeString("H4sICHC/I2EEAG1ldGFkYXRhAAEeAOH/bmFtZTogZwp2ZXJzaW9uOgogIHZlcnNpb246IDEKWw35Tx4AAAA=")
+ data := createArchive("metadata.gz", content)
+
+ rp, err := ParsePackageMetaData(data)
+ require.NoError(t, err)
+ assert.NotNil(t, rp)
+ })
+}
+
+func TestParseMetadataFile(t *testing.T) {
+ content, _ := base64.StdEncoding.DecodeString(`H4sIAMe7I2ECA9VVTW/UMBC9+1eYXvaUbJpSQBZUHJAqDlwK4kCFIseZzZrGH9iTqisEv52Js9nd
+0KqggiqRXWnX45n3ZuZ5nCzL+JPQ15ulq7+AQnEORoj3HpReaSVRO8usNCB4qxEku4YQySbuCPo4
+bjHOd07HeZGfMt9JXLlgBB9imOxx7UIULOPnCZMMLsDXXgeiYbW2jQ6C0y9TELBSa6kJ6/IzaySS
+R1mUx1nxIitPeFGI9M2L6eGfWAMebANWaUgktzN9M3lsKNmxutBb1AYyCibbNhsDFu+q9GK/Tc4z
+d2IcLBl9js5eHaXFsLyvXeNz0LQyL/YoLx8EsiCMBZlx46k6sS2PDD5AgA5kJPNKdhH2elWzOv7n
+uv9Q9Aau/6ngP84elvNpXh5oRVlB5/yW7BH0+qu0G4gqaI/JdEHBFBS5l+pKtsARIjIwUnfj8Le0
++TrdJLl2DG5A9SjrjgZ1mG+4QbAD+G4ZZBUap6qVnnzGf6Rwp+vliBRqtnYGPBEKvkb0USyXE8mS
+dVoR6hj07u0HZgAl3SRS8G/fmXcRK20jyq6rDMSYQFgidamqkXbbuspLXE/0k7GphtKqe67GuRC/
+yjAbmt9LsOMp8xMamFkSQ38fP5EFjdz8LA4do2C69VvqWXAJgrPbKZb58/xZXrKoW6ttW13Bhvzi
+4ftn7/yUxd4YGcglvTmmY8aGY3ZwRn4CqcWcidUGAAA=`)
+ rp, err := parseMetadataFile(bytes.NewReader(content))
+ require.NoError(t, err)
+ assert.NotNil(t, rp)
+
+ assert.Equal(t, "gitea", rp.Name)
+ assert.Equal(t, "1.0.5", rp.Version)
+ assert.Equal(t, "ruby", rp.Metadata.Platform)
+ assert.Equal(t, "Gitea package", rp.Metadata.Summary)
+ assert.Equal(t, "RubyGems package test", rp.Metadata.Description)
+ assert.Equal(t, []string{"Gitea"}, rp.Metadata.Authors)
+ assert.Equal(t, "https://gitea.io/", rp.Metadata.ProjectURL)
+ assert.Equal(t, []string{"MIT"}, rp.Metadata.Licenses)
+ assert.Empty(t, rp.Metadata.RequiredRubygemsVersion)
+ assert.Len(t, rp.Metadata.RequiredRubyVersion, 1)
+ assert.Equal(t, ">=", rp.Metadata.RequiredRubyVersion[0].Restriction)
+ assert.Equal(t, "2.3.0", rp.Metadata.RequiredRubyVersion[0].Version)
+ assert.Len(t, rp.Metadata.RuntimeDependencies, 1)
+ assert.Equal(t, "runtime-dep", rp.Metadata.RuntimeDependencies[0].Name)
+ assert.Len(t, rp.Metadata.RuntimeDependencies[0].Version, 2)
+ assert.Equal(t, ">=", rp.Metadata.RuntimeDependencies[0].Version[0].Restriction)
+ assert.Equal(t, "1.2.0", rp.Metadata.RuntimeDependencies[0].Version[0].Version)
+ assert.Equal(t, "<", rp.Metadata.RuntimeDependencies[0].Version[1].Restriction)
+ assert.Equal(t, "2.0", rp.Metadata.RuntimeDependencies[0].Version[1].Version)
+ assert.Len(t, rp.Metadata.DevelopmentDependencies, 1)
+ assert.Equal(t, "dev-dep", rp.Metadata.DevelopmentDependencies[0].Name)
+ assert.Len(t, rp.Metadata.DevelopmentDependencies[0].Version, 1)
+ assert.Equal(t, "~>", rp.Metadata.DevelopmentDependencies[0].Version[0].Restriction)
+ assert.Equal(t, "5.2", rp.Metadata.DevelopmentDependencies[0].Version[0].Version)
+}
diff --git a/modules/packages/swift/metadata.go b/modules/packages/swift/metadata.go
new file mode 100644
index 0000000..24c4262
--- /dev/null
+++ b/modules/packages/swift/metadata.go
@@ -0,0 +1,214 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package swift
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "path"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "github.com/hashicorp/go-version"
+)
+
+var (
+ ErrMissingManifestFile = util.NewInvalidArgumentErrorf("Package.swift file is missing")
+ ErrManifestFileTooLarge = util.NewInvalidArgumentErrorf("Package.swift file is too large")
+ ErrInvalidManifestVersion = util.NewInvalidArgumentErrorf("manifest version is invalid")
+
+ manifestPattern = regexp.MustCompile(`\APackage(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift\z`)
+ toolsVersionPattern = regexp.MustCompile(`\A// swift-tools-version:(\d+(?:\.\d+)?(?:\.\d+)?)`)
+)
+
+const (
+ maxManifestFileSize = 128 * 1024
+
+ PropertyScope = "swift.scope"
+ PropertyName = "swift.name"
+ PropertyRepositoryURL = "swift.repository_url"
+)
+
+// Package represents a Swift package
+type Package struct {
+ RepositoryURLs []string
+ Metadata *Metadata
+}
+
+// Metadata represents the metadata of a Swift package
+type Metadata struct {
+ Description string `json:"description,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+ License string `json:"license,omitempty"`
+ Author Person `json:"author,omitempty"`
+ Manifests map[string]*Manifest `json:"manifests,omitempty"`
+}
+
+// Manifest represents a Package.swift file
+type Manifest struct {
+ Content string `json:"content"`
+ ToolsVersion string `json:"tools_version,omitempty"`
+}
+
+// https://schema.org/SoftwareSourceCode
+type SoftwareSourceCode struct {
+ Context []string `json:"@context"`
+ Type string `json:"@type"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Description string `json:"description,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ CodeRepository string `json:"codeRepository,omitempty"`
+ License string `json:"license,omitempty"`
+ Author Person `json:"author"`
+ ProgrammingLanguage ProgrammingLanguage `json:"programmingLanguage"`
+ RepositoryURLs []string `json:"repositoryURLs,omitempty"`
+}
+
+// https://schema.org/ProgrammingLanguage
+type ProgrammingLanguage struct {
+ Type string `json:"@type"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+}
+
+// https://schema.org/Person
+type Person struct {
+ Type string `json:"@type,omitempty"`
+ GivenName string `json:"givenName,omitempty"`
+ MiddleName string `json:"middleName,omitempty"`
+ FamilyName string `json:"familyName,omitempty"`
+}
+
+func (p Person) String() string {
+ var sb strings.Builder
+ if p.GivenName != "" {
+ sb.WriteString(p.GivenName)
+ }
+ if p.MiddleName != "" {
+ if sb.Len() > 0 {
+ sb.WriteRune(' ')
+ }
+ sb.WriteString(p.MiddleName)
+ }
+ if p.FamilyName != "" {
+ if sb.Len() > 0 {
+ sb.WriteRune(' ')
+ }
+ sb.WriteString(p.FamilyName)
+ }
+ return sb.String()
+}
+
+// ParsePackage parses the Swift package upload
+func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
+ zr, err := zip.NewReader(sr, size)
+ if err != nil {
+ return nil, err
+ }
+
+ p := &Package{
+ Metadata: &Metadata{
+ Manifests: make(map[string]*Manifest),
+ },
+ }
+
+ for _, file := range zr.File {
+ manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
+ if len(manifestMatch) == 0 {
+ continue
+ }
+
+ if file.UncompressedSize64 > maxManifestFileSize {
+ return nil, ErrManifestFileTooLarge
+ }
+
+ f, err := zr.Open(file.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ content, err := io.ReadAll(f)
+
+ if err := f.Close(); err != nil {
+ return nil, err
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ swiftVersion := ""
+ if len(manifestMatch) == 2 && manifestMatch[1] != "" {
+ v, err := version.NewSemver(manifestMatch[1])
+ if err != nil {
+ return nil, ErrInvalidManifestVersion
+ }
+ swiftVersion = TrimmedVersionString(v)
+ }
+
+ manifest := &Manifest{
+ Content: string(content),
+ }
+
+ toolsMatch := toolsVersionPattern.FindStringSubmatch(manifest.Content)
+ if len(toolsMatch) == 2 {
+ v, err := version.NewSemver(toolsMatch[1])
+ if err != nil {
+ return nil, ErrInvalidManifestVersion
+ }
+
+ manifest.ToolsVersion = TrimmedVersionString(v)
+ }
+
+ p.Metadata.Manifests[swiftVersion] = manifest
+ }
+
+ if _, found := p.Metadata.Manifests[""]; !found {
+ return nil, ErrMissingManifestFile
+ }
+
+ if mr != nil {
+ var ssc *SoftwareSourceCode
+ if err := json.NewDecoder(mr).Decode(&ssc); err != nil {
+ return nil, err
+ }
+
+ p.Metadata.Description = ssc.Description
+ p.Metadata.Keywords = ssc.Keywords
+ p.Metadata.License = ssc.License
+ p.Metadata.Author = Person{
+ GivenName: ssc.Author.GivenName,
+ MiddleName: ssc.Author.MiddleName,
+ FamilyName: ssc.Author.FamilyName,
+ }
+
+ p.Metadata.RepositoryURL = ssc.CodeRepository
+ if !validation.IsValidURL(p.Metadata.RepositoryURL) {
+ p.Metadata.RepositoryURL = ""
+ }
+
+ p.RepositoryURLs = ssc.RepositoryURLs
+ }
+
+ return p, nil
+}
+
+// TrimmedVersionString returns the version string without the patch segment if it is zero
+func TrimmedVersionString(v *version.Version) string {
+ segments := v.Segments64()
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "%d.%d", segments[0], segments[1])
+ if segments[2] != 0 {
+ fmt.Fprintf(&b, ".%d", segments[2])
+ }
+ return b.String()
+}
diff --git a/modules/packages/swift/metadata_test.go b/modules/packages/swift/metadata_test.go
new file mode 100644
index 0000000..b223d8c
--- /dev/null
+++ b/modules/packages/swift/metadata_test.go
@@ -0,0 +1,145 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package swift
+
+import (
+ "archive/zip"
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/go-version"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ packageName = "gitea"
+ packageVersion = "1.0.1"
+ packageDescription = "Package Description"
+ packageRepositoryURL = "https://gitea.io/gitea/gitea"
+ packageAuthor = "KN4CK3R"
+ packageLicense = "MIT"
+)
+
+func TestParsePackage(t *testing.T) {
+ createArchive := func(files map[string][]byte) *bytes.Reader {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for filename, content := range files {
+ w, _ := zw.Create(filename)
+ w.Write(content)
+ }
+ zw.Close()
+ return bytes.NewReader(buf.Bytes())
+ }
+
+ t.Run("MissingManifestFile", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"dummy.txt": {}})
+
+ p, err := ParsePackage(data, data.Size(), nil)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrMissingManifestFile)
+ })
+
+ t.Run("ManifestFileTooLarge", func(t *testing.T) {
+ data := createArchive(map[string][]byte{
+ "Package.swift": make([]byte, maxManifestFileSize+1),
+ })
+
+ p, err := ParsePackage(data, data.Size(), nil)
+ assert.Nil(t, p)
+ require.ErrorIs(t, err, ErrManifestFileTooLarge)
+ })
+
+ t.Run("WithoutMetadata", func(t *testing.T) {
+ content1 := "// swift-tools-version:5.7\n//\n// Package.swift"
+ content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift"
+
+ data := createArchive(map[string][]byte{
+ "Package.swift": []byte(content1),
+ "Package@swift-5.5.swift": []byte(content2),
+ })
+
+ p, err := ParsePackage(data, data.Size(), nil)
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.NotNil(t, p.Metadata)
+ assert.Empty(t, p.RepositoryURLs)
+ assert.Len(t, p.Metadata.Manifests, 2)
+ m := p.Metadata.Manifests[""]
+ assert.Equal(t, "5.7", m.ToolsVersion)
+ assert.Equal(t, content1, m.Content)
+ m = p.Metadata.Manifests["5.5"]
+ assert.Equal(t, "5.6", m.ToolsVersion)
+ assert.Equal(t, content2, m.Content)
+ })
+
+ t.Run("WithMetadata", func(t *testing.T) {
+ data := createArchive(map[string][]byte{
+ "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"),
+ })
+
+ p, err := ParsePackage(
+ data,
+ data.Size(),
+ strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`),
+ )
+ assert.NotNil(t, p)
+ require.NoError(t, err)
+
+ assert.NotNil(t, p.Metadata)
+ assert.Len(t, p.Metadata.Manifests, 1)
+ m := p.Metadata.Manifests[""]
+ assert.Equal(t, "5.7", m.ToolsVersion)
+
+ assert.Equal(t, packageDescription, p.Metadata.Description)
+ assert.ElementsMatch(t, []string{"swift", "package"}, p.Metadata.Keywords)
+ assert.Equal(t, packageLicense, p.Metadata.License)
+ assert.Equal(t, packageAuthor, p.Metadata.Author.GivenName)
+ assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL)
+ assert.ElementsMatch(t, []string{packageRepositoryURL}, p.RepositoryURLs)
+ })
+}
+
+func TestTrimmedVersionString(t *testing.T) {
+ cases := []struct {
+ Version *version.Version
+ Expected string
+ }{
+ {
+ Version: version.Must(version.NewVersion("1")),
+ Expected: "1.0",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0")),
+ Expected: "1.0",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0.0")),
+ Expected: "1.0",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0.1")),
+ Expected: "1.0.1",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0+meta")),
+ Expected: "1.0",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0.0+meta")),
+ Expected: "1.0",
+ },
+ {
+ Version: version.Must(version.NewVersion("1.0.1+meta")),
+ Expected: "1.0.1",
+ },
+ }
+
+ for _, c := range cases {
+ assert.Equal(t, c.Expected, TrimmedVersionString(c.Version))
+ }
+}
diff --git a/modules/packages/vagrant/metadata.go b/modules/packages/vagrant/metadata.go
new file mode 100644
index 0000000..6789533
--- /dev/null
+++ b/modules/packages/vagrant/metadata.go
@@ -0,0 +1,96 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vagrant
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/validation"
+)
+
+const (
+ PropertyProvider = "vagrant.provider"
+)
+
+// Metadata represents the metadata of a Vagrant package
+type Metadata struct {
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ ProjectURL string `json:"project_url,omitempty"`
+ RepositoryURL string `json:"repository_url,omitempty"`
+}
+
+// ParseMetadataFromBox parses the metadata of a box file
+func ParseMetadataFromBox(r io.Reader) (*Metadata, error) {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ defer gzr.Close()
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.Typeflag != tar.TypeReg {
+ continue
+ }
+
+ if hd.Name == "info.json" {
+ return ParseInfoFile(tr)
+ }
+ }
+
+ return &Metadata{}, nil
+}
+
+// ParseInfoFile parses a info.json file to retrieve the metadata of a Vagrant package
+func ParseInfoFile(r io.Reader) (*Metadata, error) {
+ var values map[string]string
+ if err := json.NewDecoder(r).Decode(&values); err != nil {
+ return nil, err
+ }
+
+ m := &Metadata{}
+
+ // There is no defined format for this file, just try the common keys
+ for k, v := range values {
+ switch strings.ToLower(k) {
+ case "description":
+ fallthrough
+ case "short_description":
+ m.Description = v
+ case "website":
+ fallthrough
+ case "homepage":
+ fallthrough
+ case "url":
+ if validation.IsValidURL(v) {
+ m.ProjectURL = v
+ }
+ case "repository":
+ fallthrough
+ case "source":
+ if validation.IsValidURL(v) {
+ m.RepositoryURL = v
+ }
+ case "author":
+ fallthrough
+ case "authors":
+ m.Author = v
+ }
+ }
+
+ return m, nil
+}
diff --git a/modules/packages/vagrant/metadata_test.go b/modules/packages/vagrant/metadata_test.go
new file mode 100644
index 0000000..f467781
--- /dev/null
+++ b/modules/packages/vagrant/metadata_test.go
@@ -0,0 +1,111 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vagrant
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ author = "gitea"
+ description = "Package Description"
+ projectURL = "https://gitea.io"
+ repositoryURL = "https://gitea.io/gitea/gitea"
+)
+
+func TestParseMetadataFromBox(t *testing.T) {
+ createArchive := func(files map[string][]byte) io.Reader {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(zw)
+ for filename, content := range files {
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ }
+ tw.Close()
+ zw.Close()
+ return &buf
+ }
+
+ t.Run("MissingInfoFile", func(t *testing.T) {
+ data := createArchive(map[string][]byte{"dummy.txt": {}})
+
+ metadata, err := ParseMetadataFromBox(data)
+ assert.NotNil(t, metadata)
+ require.NoError(t, err)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ content, err := json.Marshal(map[string]string{
+ "description": description,
+ "author": author,
+ "website": projectURL,
+ "repository": repositoryURL,
+ })
+ require.NoError(t, err)
+
+ data := createArchive(map[string][]byte{"info.json": content})
+
+ metadata, err := ParseMetadataFromBox(data)
+ assert.NotNil(t, metadata)
+ require.NoError(t, err)
+
+ assert.Equal(t, author, metadata.Author)
+ assert.Equal(t, description, metadata.Description)
+ assert.Equal(t, projectURL, metadata.ProjectURL)
+ assert.Equal(t, repositoryURL, metadata.RepositoryURL)
+ })
+}
+
+func TestParseInfoFile(t *testing.T) {
+ t.Run("UnknownKeys", func(t *testing.T) {
+ content, err := json.Marshal(map[string]string{
+ "package": "",
+ "dummy": "",
+ })
+ require.NoError(t, err)
+
+ metadata, err := ParseInfoFile(bytes.NewReader(content))
+ assert.NotNil(t, metadata)
+ require.NoError(t, err)
+
+ assert.Empty(t, metadata.Author)
+ assert.Empty(t, metadata.Description)
+ assert.Empty(t, metadata.ProjectURL)
+ assert.Empty(t, metadata.RepositoryURL)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ content, err := json.Marshal(map[string]string{
+ "description": description,
+ "author": author,
+ "website": projectURL,
+ "repository": repositoryURL,
+ })
+ require.NoError(t, err)
+
+ metadata, err := ParseInfoFile(bytes.NewReader(content))
+ assert.NotNil(t, metadata)
+ require.NoError(t, err)
+
+ assert.Equal(t, author, metadata.Author)
+ assert.Equal(t, description, metadata.Description)
+ assert.Equal(t, projectURL, metadata.ProjectURL)
+ assert.Equal(t, repositoryURL, metadata.RepositoryURL)
+ })
+}
diff --git a/modules/paginator/paginator.go b/modules/paginator/paginator.go
new file mode 100644
index 0000000..8258d19
--- /dev/null
+++ b/modules/paginator/paginator.go
@@ -0,0 +1,204 @@
+// Copyright 2022 The Gitea Authors.
+// Copyright 2015 https://github.com/unknwon. Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+package paginator
+
+/*
+In template:
+
+```html
+{{if not .Page.IsFirst}}[First](1){{end}}
+{{if .Page.HasPrevious}}[Previous]({{.Page.Previous}}){{end}}
+
+{{range .Page.Pages}}
+ {{if eq .Num -1}}
+ ...
+ {{else}}
+ {{.Num}}{{if .IsCurrent}}(current){{end}}
+ {{end}}
+{{end}}
+
+{{if .Page.HasNext}}[Next]({{.Page.Next}}){{end}}
+{{if not .Page.IsLast}}[Last]({{.Page.TotalPages}}){{end}}
+```
+
+Output:
+
+```
+[First](1) [Previous](2) ... 2 3(current) 4 ... [Next](4) [Last](5)
+```
+*/
+
+// Paginator represents a set of results of pagination calculations.
+type Paginator struct {
+ total int // total rows count
+ pagingNum int // how many rows in one page
+ current int // current page number
+ numPages int // how many pages to show on the UI
+}
+
+// New initialize a new pagination calculation and returns a Paginator as result.
+func New(total, pagingNum, current, numPages int) *Paginator {
+ if pagingNum <= 0 {
+ pagingNum = 1
+ }
+ if current <= 0 {
+ current = 1
+ }
+ p := &Paginator{total, pagingNum, current, numPages}
+ if p.current > p.TotalPages() {
+ p.current = p.TotalPages()
+ }
+ return p
+}
+
+// IsFirst returns true if current page is the first page.
+func (p *Paginator) IsFirst() bool {
+ return p.current == 1
+}
+
+// HasPrevious returns true if there is a previous page relative to current page.
+func (p *Paginator) HasPrevious() bool {
+ return p.current > 1
+}
+
+func (p *Paginator) Previous() int {
+ if !p.HasPrevious() {
+ return p.current
+ }
+ return p.current - 1
+}
+
+// HasNext returns true if there is a next page relative to current page.
+func (p *Paginator) HasNext() bool {
+ return p.total > p.current*p.pagingNum
+}
+
+func (p *Paginator) Next() int {
+ if !p.HasNext() {
+ return p.current
+ }
+ return p.current + 1
+}
+
+// IsLast returns true if current page is the last page.
+func (p *Paginator) IsLast() bool {
+ if p.total == 0 {
+ return true
+ }
+ return p.total > (p.current-1)*p.pagingNum && !p.HasNext()
+}
+
+// Total returns number of total rows.
+func (p *Paginator) Total() int {
+ return p.total
+}
+
+// TotalPages returns number of total pages.
+func (p *Paginator) TotalPages() int {
+ if p.total == 0 {
+ return 1
+ }
+ return (p.total + p.pagingNum - 1) / p.pagingNum
+}
+
+// Current returns current page number.
+func (p *Paginator) Current() int {
+ return p.current
+}
+
+// PagingNum returns number of page size.
+func (p *Paginator) PagingNum() int {
+ return p.pagingNum
+}
+
+// Page presents a page in the paginator.
+type Page struct {
+ num int
+ isCurrent bool
+}
+
+func (p *Page) Num() int {
+ return p.num
+}
+
+func (p *Page) IsCurrent() bool {
+ return p.isCurrent
+}
+
+func getMiddleIdx(numPages int) int {
+ return (numPages + 1) / 2
+}
+
+// Pages returns a list of nearby page numbers relative to current page.
+// If value is -1 means "..." that more pages are not showing.
+func (p *Paginator) Pages() []*Page {
+ if p.numPages == 0 {
+ return []*Page{}
+ } else if p.numPages == 1 && p.TotalPages() == 1 {
+ // Only show current page.
+ return []*Page{{1, true}}
+ }
+
+ // Total page number is less or equal.
+ if p.TotalPages() <= p.numPages {
+ pages := make([]*Page, p.TotalPages())
+ for i := range pages {
+ pages[i] = &Page{i + 1, i+1 == p.current}
+ }
+ return pages
+ }
+
+ numPages := p.numPages
+ offsetIdx := 0
+ hasMoreNext := false
+
+ // Check more previous and next pages.
+ previousNum := getMiddleIdx(p.numPages) - 1
+ if previousNum > p.current-1 {
+ previousNum -= previousNum - (p.current - 1)
+ }
+ nextNum := p.numPages - previousNum - 1
+ if p.current+nextNum > p.TotalPages() {
+ delta := nextNum - (p.TotalPages() - p.current)
+ nextNum -= delta
+ previousNum += delta
+ }
+
+ offsetVal := p.current - previousNum
+ if offsetVal > 1 {
+ numPages++
+ offsetIdx = 1
+ }
+
+ if p.current+nextNum < p.TotalPages() {
+ numPages++
+ hasMoreNext = true
+ }
+
+ pages := make([]*Page, numPages)
+
+ // There are more previous pages.
+ if offsetIdx == 1 {
+ pages[0] = &Page{-1, false}
+ }
+ // There are more next pages.
+ if hasMoreNext {
+ pages[len(pages)-1] = &Page{-1, false}
+ }
+
+ // Check previous pages.
+ for i := 0; i < previousNum; i++ {
+ pages[offsetIdx+i] = &Page{i + offsetVal, false}
+ }
+
+ pages[offsetIdx+previousNum] = &Page{p.current, true}
+
+ // Check next pages.
+ for i := 1; i <= nextNum; i++ {
+ pages[offsetIdx+previousNum+i] = &Page{p.current + i, false}
+ }
+
+ return pages
+}
diff --git a/modules/paginator/paginator_test.go b/modules/paginator/paginator_test.go
new file mode 100644
index 0000000..8a56ee5
--- /dev/null
+++ b/modules/paginator/paginator_test.go
@@ -0,0 +1,312 @@
+// Copyright 2022 The Gitea Authors.
+// Copyright 2015 https://github.com/unknwon. Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+package paginator
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPaginator(t *testing.T) {
+ t.Run("Basic logics", func(t *testing.T) {
+ p := New(0, -1, -1, 0)
+ assert.Equal(t, 1, p.PagingNum())
+ assert.True(t, p.IsFirst())
+ assert.False(t, p.HasPrevious())
+ assert.Equal(t, 1, p.Previous())
+ assert.False(t, p.HasNext())
+ assert.Equal(t, 1, p.Next())
+ assert.True(t, p.IsLast())
+ assert.Equal(t, 0, p.Total())
+
+ p = New(1, 10, 2, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.True(t, p.IsFirst())
+ assert.False(t, p.HasPrevious())
+ assert.False(t, p.HasNext())
+ assert.True(t, p.IsLast())
+
+ p = New(10, 10, 1, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.True(t, p.IsFirst())
+ assert.False(t, p.HasPrevious())
+ assert.False(t, p.HasNext())
+ assert.True(t, p.IsLast())
+
+ p = New(11, 10, 1, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.True(t, p.IsFirst())
+ assert.False(t, p.HasPrevious())
+ assert.True(t, p.HasNext())
+ assert.Equal(t, 2, p.Next())
+ assert.False(t, p.IsLast())
+
+ p = New(11, 10, 2, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.False(t, p.IsFirst())
+ assert.True(t, p.HasPrevious())
+ assert.Equal(t, 1, p.Previous())
+ assert.False(t, p.HasNext())
+ assert.True(t, p.IsLast())
+
+ p = New(20, 10, 2, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.False(t, p.IsFirst())
+ assert.True(t, p.HasPrevious())
+ assert.False(t, p.HasNext())
+ assert.True(t, p.IsLast())
+
+ p = New(25, 10, 2, 0)
+ assert.Equal(t, 10, p.PagingNum())
+ assert.False(t, p.IsFirst())
+ assert.True(t, p.HasPrevious())
+ assert.True(t, p.HasNext())
+ assert.False(t, p.IsLast())
+ })
+
+ t.Run("Generate pages", func(t *testing.T) {
+ p := New(0, 10, 1, 0)
+ pages := p.Pages()
+ assert.Empty(t, pages)
+ })
+
+ t.Run("Only current page", func(t *testing.T) {
+ p := New(0, 10, 1, 1)
+ pages := p.Pages()
+ assert.Len(t, pages, 1)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+
+ p = New(1, 10, 1, 1)
+ pages = p.Pages()
+ assert.Len(t, pages, 1)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+ })
+
+ t.Run("Total page number is less or equal", func(t *testing.T) {
+ p := New(1, 10, 1, 2)
+ pages := p.Pages()
+ assert.Len(t, pages, 1)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+
+ p = New(11, 10, 1, 2)
+ pages = p.Pages()
+ assert.Len(t, pages, 2)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+
+ p = New(11, 10, 2, 2)
+ pages = p.Pages()
+ assert.Len(t, pages, 2)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+
+ p = New(25, 10, 2, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 3)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ })
+
+ t.Run("Has more previous pages ", func(t *testing.T) {
+ // ... 2
+ p := New(11, 10, 2, 1)
+ pages := p.Pages()
+ assert.Len(t, pages, 2)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+
+ // ... 2 3
+ p = New(21, 10, 2, 2)
+ pages = p.Pages()
+ assert.Len(t, pages, 3)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+
+ // ... 2 3 4
+ p = New(31, 10, 3, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 4)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.True(t, pages[2].IsCurrent())
+ assert.Equal(t, 4, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+
+ // ... 3 4 5
+ p = New(41, 10, 4, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 4)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 3, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, 4, pages[2].Num())
+ assert.True(t, pages[2].IsCurrent())
+ assert.Equal(t, 5, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+
+ // ... 4 5 6 7 8 9 10
+ p = New(100, 10, 9, 7)
+ pages = p.Pages()
+ assert.Len(t, pages, 8)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 4, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, 5, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, 6, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+ assert.Equal(t, 7, pages[4].Num())
+ assert.False(t, pages[4].IsCurrent())
+ assert.Equal(t, 8, pages[5].Num())
+ assert.False(t, pages[5].IsCurrent())
+ assert.Equal(t, 9, pages[6].Num())
+ assert.True(t, pages[6].IsCurrent())
+ assert.Equal(t, 10, pages[7].Num())
+ assert.False(t, pages[7].IsCurrent())
+ })
+
+ t.Run("Has more next pages", func(t *testing.T) {
+ // 1 ...
+ p := New(21, 10, 1, 1)
+ pages := p.Pages()
+ assert.Len(t, pages, 2)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+ assert.Equal(t, -1, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+
+ // 1 2 ...
+ p = New(21, 10, 1, 2)
+ pages = p.Pages()
+ assert.Len(t, pages, 3)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, -1, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+
+ // 1 2 3 ...
+ p = New(31, 10, 2, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 4)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, -1, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+
+ // 1 2 3 ...
+ p = New(41, 10, 2, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 4)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, -1, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+
+ // 1 2 3 4 5 6 7 ...
+ p = New(100, 10, 1, 7)
+ pages = p.Pages()
+ assert.Len(t, pages, 8)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.True(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, 4, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+ assert.Equal(t, 5, pages[4].Num())
+ assert.False(t, pages[4].IsCurrent())
+ assert.Equal(t, 6, pages[5].Num())
+ assert.False(t, pages[5].IsCurrent())
+ assert.Equal(t, 7, pages[6].Num())
+ assert.False(t, pages[6].IsCurrent())
+ assert.Equal(t, -1, pages[7].Num())
+ assert.False(t, pages[7].IsCurrent())
+
+ // 1 2 3 4 5 6 7 ...
+ p = New(100, 10, 2, 7)
+ pages = p.Pages()
+ assert.Len(t, pages, 8)
+ assert.Equal(t, 1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, 4, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+ assert.Equal(t, 5, pages[4].Num())
+ assert.False(t, pages[4].IsCurrent())
+ assert.Equal(t, 6, pages[5].Num())
+ assert.False(t, pages[5].IsCurrent())
+ assert.Equal(t, 7, pages[6].Num())
+ assert.False(t, pages[6].IsCurrent())
+ assert.Equal(t, -1, pages[7].Num())
+ assert.False(t, pages[7].IsCurrent())
+ })
+
+ t.Run("Has both more previous and next pages", func(t *testing.T) {
+ // ... 2 3 ...
+ p := New(35, 10, 2, 2)
+ pages := p.Pages()
+ assert.Len(t, pages, 4)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.True(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.False(t, pages[2].IsCurrent())
+ assert.Equal(t, -1, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+
+ // ... 2 3 4 ...
+ p = New(49, 10, 3, 3)
+ pages = p.Pages()
+ assert.Len(t, pages, 5)
+ assert.Equal(t, -1, pages[0].Num())
+ assert.False(t, pages[0].IsCurrent())
+ assert.Equal(t, 2, pages[1].Num())
+ assert.False(t, pages[1].IsCurrent())
+ assert.Equal(t, 3, pages[2].Num())
+ assert.True(t, pages[2].IsCurrent())
+ assert.Equal(t, 4, pages[3].Num())
+ assert.False(t, pages[3].IsCurrent())
+ assert.Equal(t, -1, pages[4].Num())
+ assert.False(t, pages[4].IsCurrent())
+ })
+}
diff --git a/modules/pprof/pprof.go b/modules/pprof/pprof.go
new file mode 100644
index 0000000..c611c14
--- /dev/null
+++ b/modules/pprof/pprof.go
@@ -0,0 +1,45 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pprof
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "runtime/pprof"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// DumpMemProfileForUsername dumps a memory profile at pprofDataPath as memprofile_<username>_<temporary id>
+func DumpMemProfileForUsername(pprofDataPath, username string) error {
+ f, err := os.CreateTemp(pprofDataPath, fmt.Sprintf("memprofile_%s_", username))
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ runtime.GC() // get up-to-date statistics
+ return pprof.WriteHeapProfile(f)
+}
+
+// DumpCPUProfileForUsername dumps a CPU profile at pprofDataPath as cpuprofile_<username>_<temporary id>
+// the stop function it returns stops, writes and closes the CPU profile file
+func DumpCPUProfileForUsername(pprofDataPath, username string) (func(), error) {
+ f, err := os.CreateTemp(pprofDataPath, fmt.Sprintf("cpuprofile_%s_", username))
+ if err != nil {
+ return nil, err
+ }
+
+ err = pprof.StartCPUProfile(f)
+ if err != nil {
+ log.Fatal("StartCPUProfile: %v", err)
+ }
+ return func() {
+ pprof.StopCPUProfile()
+ err = f.Close()
+ if err != nil {
+ log.Fatal("StopCPUProfile Close: %v", err)
+ }
+ }, nil
+}
diff --git a/modules/private/actions.go b/modules/private/actions.go
new file mode 100644
index 0000000..311a283
--- /dev/null
+++ b/modules/private/actions.go
@@ -0,0 +1,25 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type GenerateTokenRequest struct {
+ Scope string
+}
+
+// GenerateActionsRunnerToken calls the internal GenerateActionsRunnerToken function
+func GenerateActionsRunnerToken(ctx context.Context, scope string) (*ResponseText, ResponseExtra) {
+ reqURL := setting.LocalURL + "api/internal/actions/generate_actions_runner_token"
+
+ req := newInternalRequest(ctx, reqURL, "POST", GenerateTokenRequest{
+ Scope: scope,
+ })
+
+ return requestJSONResp(req, &ResponseText{})
+}
diff --git a/modules/private/forgejo_actions.go b/modules/private/forgejo_actions.go
new file mode 100644
index 0000000..133d5e2
--- /dev/null
+++ b/modules/private/forgejo_actions.go
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type ActionsRunnerRegisterRequest struct {
+ Token string
+ Scope string
+ Labels []string
+ Name string
+ Version string
+}
+
+func ActionsRunnerRegister(ctx context.Context, token, scope string, labels []string, name, version string) (string, ResponseExtra) {
+ reqURL := setting.LocalURL + "api/internal/actions/register"
+
+ req := newInternalRequest(ctx, reqURL, "POST", ActionsRunnerRegisterRequest{
+ Token: token,
+ Scope: scope,
+ Labels: labels,
+ Name: name,
+ Version: version,
+ })
+
+ resp, extra := requestJSONResp(req, &ResponseText{})
+ return resp.Text, extra
+}
diff --git a/modules/private/hook.go b/modules/private/hook.go
new file mode 100644
index 0000000..93cbcd4
--- /dev/null
+++ b/modules/private/hook.go
@@ -0,0 +1,129 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "time"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/git/pushoptions"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Git environment variables
+const (
+ GitAlternativeObjectDirectories = "GIT_ALTERNATE_OBJECT_DIRECTORIES"
+ GitObjectDirectory = "GIT_OBJECT_DIRECTORY"
+ GitQuarantinePath = "GIT_QUARANTINE_PATH"
+)
+
+// HookOptions represents the options for the Hook calls
+type HookOptions struct {
+ OldCommitIDs []string
+ NewCommitIDs []string
+ RefFullNames []git.RefName
+ UserID int64
+ UserName string
+ GitObjectDirectory string
+ GitAlternativeObjectDirectories string
+ GitQuarantinePath string
+ GitPushOptions map[string]string
+ PullRequestID int64
+ PushTrigger repository.PushTrigger
+ DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
+ IsWiki bool
+ ActionPerm int
+}
+
+func (o *HookOptions) GetGitPushOptions() pushoptions.Interface {
+ return pushoptions.NewFromMap(&o.GitPushOptions)
+}
+
+// SSHLogOption ssh log options
+type SSHLogOption struct {
+ IsError bool
+ Message string
+}
+
+// HookPostReceiveResult represents an individual result from PostReceive
+type HookPostReceiveResult struct {
+ Results []HookPostReceiveBranchResult
+ RepoWasEmpty bool
+ Err string
+}
+
+// HookPostReceiveBranchResult represents an individual branch result from PostReceive
+type HookPostReceiveBranchResult struct {
+ Message bool
+ Create bool
+ Branch string
+ URL string
+}
+
+// HookProcReceiveResult represents an individual result from ProcReceive
+type HookProcReceiveResult struct {
+ Results []HookProcReceiveRefResult
+ Err string
+}
+
+// HookProcReceiveRefResult represents an individual result from ProcReceive
+type HookProcReceiveRefResult struct {
+ OldOID string
+ NewOID string
+ Ref string
+ OriginalRef git.RefName
+ IsForcePush bool
+ IsNotMatched bool
+ Err string
+}
+
+// HookPreReceive check whether the provided commits are allowed
+func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) ResponseExtra {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/pre-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
+ req := newInternalRequest(ctx, reqURL, "POST", opts)
+ req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
+ _, extra := requestJSONResp(req, &ResponseText{})
+ return extra
+}
+
+// HookPostReceive updates services and users
+func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookPostReceiveResult, ResponseExtra) {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/post-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
+ req := newInternalRequest(ctx, reqURL, "POST", opts)
+ req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
+ return requestJSONResp(req, &HookPostReceiveResult{})
+}
+
+// HookProcReceive proc-receive hook
+func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookProcReceiveResult, ResponseExtra) {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/proc-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
+
+ req := newInternalRequest(ctx, reqURL, "POST", opts)
+ req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
+ return requestJSONResp(req, &HookProcReceiveResult{})
+}
+
+// SetDefaultBranch will set the default branch to the provided branch for the provided repository
+func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) ResponseExtra {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s",
+ url.PathEscape(ownerName),
+ url.PathEscape(repoName),
+ url.PathEscape(branch),
+ )
+ req := newInternalRequest(ctx, reqURL, "POST")
+ _, extra := requestJSONResp(req, &ResponseText{})
+ return extra
+}
+
+// SSHLog sends ssh error log response
+func SSHLog(ctx context.Context, isErr bool, msg string) error {
+ reqURL := setting.LocalURL + "api/internal/ssh/log"
+ req := newInternalRequest(ctx, reqURL, "POST", &SSHLogOption{IsError: isErr, Message: msg})
+ _, extra := requestJSONResp(req, &ResponseText{})
+ return extra.Error
+}
diff --git a/modules/private/internal.go b/modules/private/internal.go
new file mode 100644
index 0000000..9c330a2
--- /dev/null
+++ b/modules/private/internal.go
@@ -0,0 +1,96 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/httplib"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/proxyprotocol"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Response is used for internal request response (for user message and error message)
+type Response struct {
+ Err string `json:"err,omitempty"` // server-side error log message, it won't be exposed to end users
+ UserMsg string `json:"user_msg,omitempty"` // meaningful error message for end users, it will be shown in git client's output.
+}
+
+func getClientIP() string {
+ sshConnEnv := strings.TrimSpace(os.Getenv("SSH_CONNECTION"))
+ if len(sshConnEnv) == 0 {
+ return "127.0.0.1"
+ }
+ return strings.Fields(sshConnEnv)[0]
+}
+
+func newInternalRequest(ctx context.Context, url, method string, body ...any) *httplib.Request {
+ if setting.InternalToken == "" {
+ log.Fatal(`The INTERNAL_TOKEN setting is missing from the configuration file: %q.
+Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
+ }
+
+ req := httplib.NewRequest(url, method).
+ SetContext(ctx).
+ Header("X-Real-IP", getClientIP()).
+ Header("Authorization", fmt.Sprintf("Bearer %s", setting.InternalToken)).
+ SetTLSClientConfig(&tls.Config{
+ InsecureSkipVerify: true,
+ ServerName: setting.Domain,
+ })
+
+ if setting.Protocol == setting.HTTPUnix {
+ req.SetTransport(&http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "unix", setting.HTTPAddr)
+ if err != nil {
+ return conn, err
+ }
+ if setting.LocalUseProxyProtocol {
+ if err = proxyprotocol.WriteLocalHeader(conn); err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ }
+ return conn, err
+ },
+ })
+ } else if setting.LocalUseProxyProtocol {
+ req.SetTransport(&http.Transport{
+ DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, network, address)
+ if err != nil {
+ return conn, err
+ }
+ if err = proxyprotocol.WriteLocalHeader(conn); err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ return conn, err
+ },
+ })
+ }
+
+ if len(body) == 1 {
+ req.Header("Content-Type", "application/json")
+ jsonBytes, _ := json.Marshal(body[0])
+ req.Body(jsonBytes)
+ } else if len(body) > 1 {
+ log.Fatal("Too many arguments for newInternalRequest")
+ }
+
+ req.SetTimeout(10*time.Second, 60*time.Second)
+ return req
+}
diff --git a/modules/private/key.go b/modules/private/key.go
new file mode 100644
index 0000000..dcd1714
--- /dev/null
+++ b/modules/private/key.go
@@ -0,0 +1,30 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// UpdatePublicKeyInRepo update public key and if necessary deploy key updates
+func UpdatePublicKeyInRepo(ctx context.Context, keyID, repoID int64) error {
+ // Ask for running deliver hook and test pull request tasks.
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/ssh/%d/update/%d", keyID, repoID)
+ req := newInternalRequest(ctx, reqURL, "POST")
+ _, extra := requestJSONResp(req, &ResponseText{})
+ return extra.Error
+}
+
+// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part)
+// and returns public key found.
+func AuthorizedPublicKeyByContent(ctx context.Context, content string) (*ResponseText, ResponseExtra) {
+ // Ask for running deliver hook and test pull request tasks.
+ reqURL := setting.LocalURL + "api/internal/ssh/authorized_keys"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ req.Param("content", content)
+ return requestJSONResp(req, &ResponseText{})
+}
diff --git a/modules/private/mail.go b/modules/private/mail.go
new file mode 100644
index 0000000..08de5b7
--- /dev/null
+++ b/modules/private/mail.go
@@ -0,0 +1,33 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Email structure holds a data for sending general emails
+type Email struct {
+ Subject string
+ Message string
+ To []string
+}
+
+// SendEmail calls the internal SendEmail function
+// It accepts a list of usernames.
+// If DB contains these users it will send the email to them.
+// If to list == nil, it's supposed to send emails to every user present in DB
+func SendEmail(ctx context.Context, subject, message string, to []string) (*ResponseText, ResponseExtra) {
+ reqURL := setting.LocalURL + "api/internal/mail/send"
+
+ req := newInternalRequest(ctx, reqURL, "POST", Email{
+ Subject: subject,
+ Message: message,
+ To: to,
+ })
+
+ return requestJSONResp(req, &ResponseText{})
+}
diff --git a/modules/private/manager.go b/modules/private/manager.go
new file mode 100644
index 0000000..6055e55
--- /dev/null
+++ b/modules/private/manager.go
@@ -0,0 +1,120 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Shutdown calls the internal shutdown function
+func Shutdown(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/shutdown"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Shutting down")
+}
+
+// Restart calls the internal restart function
+func Restart(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/restart"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Restarting")
+}
+
+// ReloadTemplates calls the internal reload-templates function
+func ReloadTemplates(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/reload-templates"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Reloaded")
+}
+
+// FlushOptions represents the options for the flush call
+type FlushOptions struct {
+ Timeout time.Duration
+ NonBlocking bool
+}
+
+// FlushQueues calls the internal flush-queues function
+func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/flush-queues"
+ req := newInternalRequest(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking})
+ if timeout > 0 {
+ req.SetReadWriteTimeout(timeout + 10*time.Second)
+ }
+ return requestJSONClientMsg(req, "Flushed")
+}
+
+// PauseLogging pauses logging
+func PauseLogging(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/pause-logging"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Logging Paused")
+}
+
+// ResumeLogging resumes logging
+func ResumeLogging(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/resume-logging"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Logging Restarted")
+}
+
+// ReleaseReopenLogging releases and reopens logging files
+func ReleaseReopenLogging(ctx context.Context) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/release-and-reopen-logging"
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Logging Restarted")
+}
+
+// SetLogSQL sets database logging
+func SetLogSQL(ctx context.Context, on bool) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/set-log-sql?on=" + strconv.FormatBool(on)
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Log SQL setting set")
+}
+
+// LoggerOptions represents the options for the add logger call
+type LoggerOptions struct {
+ Logger string
+ Writer string
+ Mode string
+ Config map[string]any
+}
+
+// AddLogger adds a logger
+func AddLogger(ctx context.Context, logger, writer, mode string, config map[string]any) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/manager/add-logger"
+ req := newInternalRequest(ctx, reqURL, "POST", LoggerOptions{
+ Logger: logger,
+ Writer: writer,
+ Mode: mode,
+ Config: config,
+ })
+ return requestJSONClientMsg(req, "Added")
+}
+
+// RemoveLogger removes a logger
+func RemoveLogger(ctx context.Context, logger, writer string) ResponseExtra {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/remove-logger/%s/%s", url.PathEscape(logger), url.PathEscape(writer))
+ req := newInternalRequest(ctx, reqURL, "POST")
+ return requestJSONClientMsg(req, "Removed")
+}
+
+// Processes return the current processes from this gitea instance
+func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) ResponseExtra {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/processes?flat=%t&no-system=%t&stacktraces=%t&json=%t&cancel-pid=%s", flat, noSystem, stacktraces, json, url.QueryEscape(cancel))
+
+ req := newInternalRequest(ctx, reqURL, "GET")
+ callback := func(resp *http.Response, extra *ResponseExtra) {
+ _, extra.Error = io.Copy(out, resp.Body)
+ }
+ _, extra := requestJSONResp(req, &responseCallback{callback})
+ return extra
+}
diff --git a/modules/private/request.go b/modules/private/request.go
new file mode 100644
index 0000000..58cd261
--- /dev/null
+++ b/modules/private/request.go
@@ -0,0 +1,128 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/httplib"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// ResponseText is used to get the response as text, instead of parsing it as JSON.
+type ResponseText struct {
+ Text string
+}
+
+// ResponseExtra contains extra information about the response, especially for error responses.
+type ResponseExtra struct {
+ StatusCode int
+ UserMsg string
+ Error error
+}
+
+type responseCallback struct {
+ Callback func(resp *http.Response, extra *ResponseExtra)
+}
+
+func (re *ResponseExtra) HasError() bool {
+ return re.Error != nil
+}
+
+type responseError struct {
+ statusCode int
+ errorString string
+}
+
+func (re responseError) Error() string {
+ if re.errorString == "" {
+ return fmt.Sprintf("internal API error response, status=%d", re.statusCode)
+ }
+ return fmt.Sprintf("internal API error response, status=%d, err=%s", re.statusCode, re.errorString)
+}
+
+// requestJSONResp sends a request to the gitea server and then parses the response.
+// If the status code is not 2xx, or any error occurs, the ResponseExtra.Error field is guaranteed to be non-nil,
+// and the ResponseExtra.UserMsg field will be set to a message for the end user.
+// Caller should check the ResponseExtra.HasError() first to see whether the request fails.
+//
+// * If the "res" is a struct pointer, the response will be parsed as JSON
+// * If the "res" is ResponseText pointer, the response will be stored as text in it
+// * If the "res" is responseCallback pointer, the callback function should set the ResponseExtra fields accordingly
+func requestJSONResp[T any](req *httplib.Request, res *T) (ret *T, extra ResponseExtra) {
+ resp, err := req.Response()
+ if err != nil {
+ extra.UserMsg = "Internal Server Connection Error"
+ extra.Error = fmt.Errorf("unable to contact gitea %q: %w", req.GoString(), err)
+ return nil, extra
+ }
+ defer resp.Body.Close()
+
+ extra.StatusCode = resp.StatusCode
+
+ // if the status code is not 2xx, try to parse the error response
+ if resp.StatusCode/100 != 2 {
+ var respErr Response
+ if err := json.NewDecoder(resp.Body).Decode(&respErr); err != nil {
+ extra.UserMsg = "Internal Server Error Decoding Failed"
+ extra.Error = fmt.Errorf("unable to decode error response %q: %w", req.GoString(), err)
+ return nil, extra
+ }
+ extra.UserMsg = respErr.UserMsg
+ if extra.UserMsg == "" {
+ extra.UserMsg = "Internal Server Error (no message for end users)"
+ }
+ extra.Error = responseError{statusCode: resp.StatusCode, errorString: respErr.Err}
+ return res, extra
+ }
+
+ // now, the StatusCode must be 2xx
+ var v any = res
+ if respText, ok := v.(*ResponseText); ok {
+ // get the whole response as a text string
+ bs, err := io.ReadAll(resp.Body)
+ if err != nil {
+ extra.UserMsg = "Internal Server Response Reading Failed"
+ extra.Error = fmt.Errorf("unable to read response %q: %w", req.GoString(), err)
+ return nil, extra
+ }
+ respText.Text = string(bs)
+ return res, extra
+ } else if cb, ok := v.(*responseCallback); ok {
+ // pass the response to callback, and let the callback update the ResponseExtra
+ extra.StatusCode = resp.StatusCode
+ cb.Callback(resp, &extra)
+ return nil, extra
+ } else if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
+ // decode the response into the given struct
+ extra.UserMsg = "Internal Server Response Decoding Failed"
+ extra.Error = fmt.Errorf("unable to decode response %q: %w", req.GoString(), err)
+ return nil, extra
+ }
+
+ if respMsg, ok := v.(*Response); ok {
+ // if the "res" is Response structure, try to get the UserMsg from it and update the ResponseExtra
+ extra.UserMsg = respMsg.UserMsg
+ if respMsg.Err != "" {
+ // usually this shouldn't happen, because the StatusCode is 2xx, there should be no error.
+ // but we still handle the "err" response, in case some people return error messages by status code 200.
+ extra.Error = responseError{statusCode: resp.StatusCode, errorString: respMsg.Err}
+ }
+ }
+
+ return res, extra
+}
+
+// requestJSONClientMsg sends a request to the gitea server, server only responds text message status=200 with "success" body
+// If the request succeeds (200), the argument clientSuccessMsg will be used as ResponseExtra.UserMsg.
+func requestJSONClientMsg(req *httplib.Request, clientSuccessMsg string) ResponseExtra {
+ _, extra := requestJSONResp(req, &ResponseText{})
+ if extra.HasError() {
+ return extra
+ }
+ extra.UserMsg = clientSuccessMsg
+ return extra
+}
diff --git a/modules/private/restore_repo.go b/modules/private/restore_repo.go
new file mode 100644
index 0000000..496209d
--- /dev/null
+++ b/modules/private/restore_repo.go
@@ -0,0 +1,36 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// RestoreParams structure holds a data for restore repository
+type RestoreParams struct {
+ RepoDir string
+ OwnerName string
+ RepoName string
+ Units []string
+ Validation bool
+}
+
+// RestoreRepo calls the internal RestoreRepo function
+func RestoreRepo(ctx context.Context, repoDir, ownerName, repoName string, units []string, validation bool) ResponseExtra {
+ reqURL := setting.LocalURL + "api/internal/restore_repo"
+
+ req := newInternalRequest(ctx, reqURL, "POST", RestoreParams{
+ RepoDir: repoDir,
+ OwnerName: ownerName,
+ RepoName: repoName,
+ Units: units,
+ Validation: validation,
+ })
+ req.SetTimeout(3*time.Second, 0) // since the request will spend much time, don't timeout
+ return requestJSONClientMsg(req, fmt.Sprintf("Restore repo %s/%s successfully", ownerName, repoName))
+}
diff --git a/modules/private/serv.go b/modules/private/serv.go
new file mode 100644
index 0000000..480a446
--- /dev/null
+++ b/modules/private/serv.go
@@ -0,0 +1,63 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/perm"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// KeyAndOwner is the response from ServNoCommand
+type KeyAndOwner struct {
+ Key *asymkey_model.PublicKey `json:"key"`
+ Owner *user_model.User `json:"user"`
+}
+
+// ServNoCommand returns information about the provided key
+func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey, *user_model.User, error) {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/none/%d", keyID)
+ req := newInternalRequest(ctx, reqURL, "GET")
+ keyAndOwner, extra := requestJSONResp(req, &KeyAndOwner{})
+ if extra.HasError() {
+ return nil, nil, extra.Error
+ }
+ return keyAndOwner.Key, keyAndOwner.Owner, nil
+}
+
+// ServCommandResults are the results of a call to the private route serv
+type ServCommandResults struct {
+ IsWiki bool
+ DeployKeyID int64
+ KeyID int64 // public key
+ KeyName string // this field is ambiguous, it can be the name of DeployKey, or the name of the PublicKey
+ UserName string
+ UserEmail string
+ UserID int64
+ OwnerName string
+ RepoName string
+ RepoID int64
+}
+
+// ServCommand preps for a serv call
+func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verbs ...string) (*ServCommandResults, ResponseExtra) {
+ reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/command/%d/%s/%s?mode=%d",
+ keyID,
+ url.PathEscape(ownerName),
+ url.PathEscape(repoName),
+ mode,
+ )
+ for _, verb := range verbs {
+ if verb != "" {
+ reqURL += fmt.Sprintf("&verb=%s", url.QueryEscape(verb))
+ }
+ }
+ req := newInternalRequest(ctx, reqURL, "GET")
+ return requestJSONResp(req, &ServCommandResults{})
+}
diff --git a/modules/process/context.go b/modules/process/context.go
new file mode 100644
index 0000000..26a80eb
--- /dev/null
+++ b/modules/process/context.go
@@ -0,0 +1,68 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "context"
+)
+
+// Context is a wrapper around context.Context and contains the current pid for this context
+type Context struct {
+ context.Context
+ pid IDType
+}
+
+// GetPID returns the PID for this context
+func (c *Context) GetPID() IDType {
+ return c.pid
+}
+
+// GetParent returns the parent process context (if any)
+func (c *Context) GetParent() *Context {
+ return GetContext(c.Context)
+}
+
+// Value is part of the interface for context.Context. We mostly defer to the internal context - but we return this in response to the ProcessContextKey
+func (c *Context) Value(key any) any {
+ if key == ProcessContextKey {
+ return c
+ }
+ return c.Context.Value(key)
+}
+
+// ProcessContextKey is the key under which process contexts are stored
+var ProcessContextKey any = "process-context"
+
+// GetContext will return a process context if one exists
+func GetContext(ctx context.Context) *Context {
+ if pCtx, ok := ctx.(*Context); ok {
+ return pCtx
+ }
+ pCtxInterface := ctx.Value(ProcessContextKey)
+ if pCtxInterface == nil {
+ return nil
+ }
+ if pCtx, ok := pCtxInterface.(*Context); ok {
+ return pCtx
+ }
+ return nil
+}
+
+// GetPID returns the PID for this context
+func GetPID(ctx context.Context) IDType {
+ pCtx := GetContext(ctx)
+ if pCtx == nil {
+ return ""
+ }
+ return pCtx.GetPID()
+}
+
+// GetParentPID returns the ParentPID for this context
+func GetParentPID(ctx context.Context) IDType {
+ var parentPID IDType
+ if parentProcess := GetContext(ctx); parentProcess != nil {
+ parentPID = parentProcess.GetPID()
+ }
+ return parentPID
+}
diff --git a/modules/process/error.go b/modules/process/error.go
new file mode 100644
index 0000000..8f02f65
--- /dev/null
+++ b/modules/process/error.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import "fmt"
+
+// Error is a wrapped error describing the error results of Process Execution
+type Error struct {
+ PID IDType
+ Description string
+ Err error
+ CtxErr error
+ Stdout string
+ Stderr string
+}
+
+func (err *Error) Error() string {
+ return fmt.Sprintf("exec(%s:%s) failed: %v(%v) stdout: %s stderr: %s", err.PID, err.Description, err.Err, err.CtxErr, err.Stdout, err.Stderr)
+}
+
+// Unwrap implements the unwrappable implicit interface for go1.13 Unwrap()
+func (err *Error) Unwrap() error {
+ return err.Err
+}
diff --git a/modules/process/manager.go b/modules/process/manager.go
new file mode 100644
index 0000000..37098ad
--- /dev/null
+++ b/modules/process/manager.go
@@ -0,0 +1,243 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "context"
+ "runtime/pprof"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// TODO: This packages still uses a singleton for the Manager.
+// Once there's a decent web framework and dependencies are passed around like they should,
+// then we delete the singleton.
+
+var (
+ manager *Manager
+ managerInit sync.Once
+
+ // DefaultContext is the default context to run processing commands in
+ DefaultContext = context.Background()
+)
+
+// DescriptionPProfLabel is a label set on goroutines that have a process attached
+const DescriptionPProfLabel = "processDescription"
+
+// PIDPProfLabel is a label set on goroutines that have a process attached
+const PIDPProfLabel = "pid"
+
+// PPIDPProfLabel is a label set on goroutines that have a process attached
+const PPIDPProfLabel = "ppid"
+
+// ProcessTypePProfLabel is a label set on goroutines that have a process attached
+const ProcessTypePProfLabel = "processType"
+
+// IDType is a pid type
+type IDType string
+
+// FinishedFunc is a function that marks that the process is finished and can be removed from the process table
+// - it is simply an alias for context.CancelFunc and is only for documentary purposes
+type FinishedFunc = context.CancelFunc
+
+var (
+ traceDisabled atomic.Int64
+ TraceCallback = defaultTraceCallback // this global can be overridden by particular logging packages - thus avoiding import cycles
+)
+
+// defaultTraceCallback is a no-op. Without a proper TraceCallback (provided by the logger system), this "Trace" level messages shouldn't be outputted.
+func defaultTraceCallback(skip int, start bool, pid IDType, description string, parentPID IDType, typ string) {
+}
+
+// TraceLogDisable disables (or revert the disabling) the trace log for the process lifecycle.
+// eg: the logger system shouldn't print the trace log for themselves, that's cycle dependency (Logger -> ProcessManager -> TraceCallback -> Logger ...)
+// Theoretically, such trace log should only be enabled when the logger system is ready with a proper level, so the default TraceCallback is a no-op.
+func TraceLogDisable(v bool) {
+ if v {
+ traceDisabled.Add(1)
+ } else {
+ traceDisabled.Add(-1)
+ }
+}
+
+func Trace(start bool, pid IDType, description string, parentPID IDType, typ string) {
+ if traceDisabled.Load() != 0 {
+ // the traceDisabled counter is mainly for recursive calls, so no concurrency problem.
+ // because the counter can't be 0 since the caller function hasn't returned (decreased the counter) yet.
+ return
+ }
+ TraceCallback(1, start, pid, description, parentPID, typ)
+}
+
+// Manager manages all processes and counts PIDs.
+type Manager struct {
+ mutex sync.Mutex
+
+ next int64
+ lastTime int64
+
+ processMap map[IDType]*process
+}
+
+// GetManager returns a Manager and initializes one as singleton if there's none yet
+func GetManager() *Manager {
+ managerInit.Do(func() {
+ manager = &Manager{
+ processMap: make(map[IDType]*process),
+ next: 1,
+ }
+ })
+ return manager
+}
+
+// AddContext creates a new context and adds it as a process. Once the process is finished, finished must be called
+// to remove the process from the process table. It should not be called until the process is finished but must always be called.
+//
+// cancel should be used to cancel the returned context, however it will not remove the process from the process table.
+// finished will cancel the returned context and remove it from the process table.
+//
+// Most processes will not need to use the cancel function but there will be cases whereby you want to cancel the process but not immediately remove it from the
+// process table.
+func (pm *Manager) AddContext(parent context.Context, description string) (ctx context.Context, cancel context.CancelFunc, finished FinishedFunc) {
+ ctx, cancel = context.WithCancel(parent)
+
+ ctx, _, finished = pm.Add(ctx, description, cancel, NormalProcessType, true)
+
+ return ctx, cancel, finished
+}
+
+// AddTypedContext creates a new context and adds it as a process. Once the process is finished, finished must be called
+// to remove the process from the process table. It should not be called until the process is finished but must always be called.
+//
+// cancel should be used to cancel the returned context, however it will not remove the process from the process table.
+// finished will cancel the returned context and remove it from the process table.
+//
+// Most processes will not need to use the cancel function but there will be cases whereby you want to cancel the process but not immediately remove it from the
+// process table.
+func (pm *Manager) AddTypedContext(parent context.Context, description, processType string, currentlyRunning bool) (ctx context.Context, cancel context.CancelFunc, finished FinishedFunc) {
+ ctx, cancel = context.WithCancel(parent)
+
+ ctx, _, finished = pm.Add(ctx, description, cancel, processType, currentlyRunning)
+
+ return ctx, cancel, finished
+}
+
+// AddContextTimeout creates a new context and add it as a process. Once the process is finished, finished must be called
+// to remove the process from the process table. It should not be called until the process is finished but must always be called.
+//
+// cancel should be used to cancel the returned context, however it will not remove the process from the process table.
+// finished will cancel the returned context and remove it from the process table.
+//
+// Most processes will not need to use the cancel function but there will be cases whereby you want to cancel the process but not immediately remove it from the
+// process table.
+func (pm *Manager) AddContextTimeout(parent context.Context, timeout time.Duration, description string) (ctx context.Context, cancel context.CancelFunc, finished FinishedFunc) {
+ if timeout <= 0 {
+ // it's meaningless to use timeout <= 0, and it must be a bug! so we must panic here to tell developers to make the timeout correct
+ panic("the timeout must be greater than zero, otherwise the context will be cancelled immediately")
+ }
+
+ ctx, cancel = context.WithTimeout(parent, timeout)
+
+ ctx, _, finished = pm.Add(ctx, description, cancel, NormalProcessType, true)
+
+ return ctx, cancel, finished
+}
+
+// Add create a new process
+func (pm *Manager) Add(ctx context.Context, description string, cancel context.CancelFunc, processType string, currentlyRunning bool) (context.Context, IDType, FinishedFunc) {
+ parentPID := GetParentPID(ctx)
+
+ pm.mutex.Lock()
+ start, pid := pm.nextPID()
+
+ parent := pm.processMap[parentPID]
+ if parent == nil {
+ parentPID = ""
+ }
+
+ process := &process{
+ PID: pid,
+ ParentPID: parentPID,
+ Description: description,
+ Start: start,
+ Cancel: cancel,
+ Type: processType,
+ }
+
+ var finished FinishedFunc
+ if currentlyRunning {
+ finished = func() {
+ cancel()
+ pm.remove(process)
+ pprof.SetGoroutineLabels(ctx)
+ }
+ } else {
+ finished = func() {
+ cancel()
+ pm.remove(process)
+ }
+ }
+
+ pm.processMap[pid] = process
+ pm.mutex.Unlock()
+
+ Trace(true, pid, description, parentPID, processType)
+
+ pprofCtx := pprof.WithLabels(ctx, pprof.Labels(DescriptionPProfLabel, description, PPIDPProfLabel, string(parentPID), PIDPProfLabel, string(pid), ProcessTypePProfLabel, processType))
+ if currentlyRunning {
+ pprof.SetGoroutineLabels(pprofCtx)
+ }
+
+ return &Context{
+ Context: pprofCtx,
+ pid: pid,
+ }, pid, finished
+}
+
+// nextPID will return the next available PID. pm.mutex should already be locked.
+func (pm *Manager) nextPID() (start time.Time, pid IDType) {
+ start = time.Now()
+ startUnix := start.Unix()
+ if pm.lastTime == startUnix {
+ pm.next++
+ } else {
+ pm.next = 1
+ }
+ pm.lastTime = startUnix
+ pid = IDType(strconv.FormatInt(start.Unix(), 16))
+
+ if pm.next == 1 {
+ return start, pid
+ }
+ pid = IDType(string(pid) + "-" + strconv.FormatInt(pm.next, 10))
+ return start, pid
+}
+
+func (pm *Manager) remove(process *process) {
+ deleted := false
+
+ pm.mutex.Lock()
+ if pm.processMap[process.PID] == process {
+ delete(pm.processMap, process.PID)
+ deleted = true
+ }
+ pm.mutex.Unlock()
+
+ if deleted {
+ Trace(false, process.PID, process.Description, process.ParentPID, process.Type)
+ }
+}
+
+// Cancel a process in the ProcessManager.
+func (pm *Manager) Cancel(pid IDType) {
+ pm.mutex.Lock()
+ process, ok := pm.processMap[pid]
+ pm.mutex.Unlock()
+ if ok && process.Type != SystemProcessType {
+ process.Cancel()
+ }
+}
diff --git a/modules/process/manager_exec.go b/modules/process/manager_exec.go
new file mode 100644
index 0000000..c983173
--- /dev/null
+++ b/modules/process/manager_exec.go
@@ -0,0 +1,79 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os/exec"
+ "time"
+)
+
+// Exec a command and use the default timeout.
+func (pm *Manager) Exec(desc, cmdName string, args ...string) (string, string, error) {
+ return pm.ExecDir(DefaultContext, -1, "", desc, cmdName, args...)
+}
+
+// ExecTimeout a command and use a specific timeout duration.
+func (pm *Manager) ExecTimeout(timeout time.Duration, desc, cmdName string, args ...string) (string, string, error) {
+ return pm.ExecDir(DefaultContext, timeout, "", desc, cmdName, args...)
+}
+
+// ExecDir a command and use the default timeout.
+func (pm *Manager) ExecDir(ctx context.Context, timeout time.Duration, dir, desc, cmdName string, args ...string) (string, string, error) {
+ return pm.ExecDirEnv(ctx, timeout, dir, desc, nil, cmdName, args...)
+}
+
+// ExecDirEnv runs a command in given path and environment variables, and waits for its completion
+// up to the given timeout (or DefaultTimeout if -1 is given).
+// Returns its complete stdout and stderr
+// outputs and an error, if any (including timeout)
+func (pm *Manager) ExecDirEnv(ctx context.Context, timeout time.Duration, dir, desc string, env []string, cmdName string, args ...string) (string, string, error) {
+ return pm.ExecDirEnvStdIn(ctx, timeout, dir, desc, env, nil, cmdName, args...)
+}
+
+// ExecDirEnvStdIn runs a command in given path and environment variables with provided stdIN, and waits for its completion
+// up to the given timeout (or DefaultTimeout if timeout <= 0 is given).
+// Returns its complete stdout and stderr
+// outputs and an error, if any (including timeout)
+func (pm *Manager) ExecDirEnvStdIn(ctx context.Context, timeout time.Duration, dir, desc string, env []string, stdIn io.Reader, cmdName string, args ...string) (string, string, error) {
+ if timeout <= 0 {
+ timeout = 60 * time.Second
+ }
+
+ stdOut := new(bytes.Buffer)
+ stdErr := new(bytes.Buffer)
+
+ ctx, _, finished := pm.AddContextTimeout(ctx, timeout, desc)
+ defer finished()
+
+ cmd := exec.CommandContext(ctx, cmdName, args...)
+ cmd.Dir = dir
+ cmd.Env = env
+ cmd.Stdout = stdOut
+ cmd.Stderr = stdErr
+ if stdIn != nil {
+ cmd.Stdin = stdIn
+ }
+ SetSysProcAttribute(cmd)
+
+ if err := cmd.Start(); err != nil {
+ return "", "", err
+ }
+
+ err := cmd.Wait()
+ if err != nil {
+ err = &Error{
+ PID: GetPID(ctx),
+ Description: desc,
+ Err: err,
+ CtxErr: ctx.Err(),
+ Stdout: stdOut.String(),
+ Stderr: stdErr.String(),
+ }
+ }
+
+ return stdOut.String(), stdErr.String(), err
+}
diff --git a/modules/process/manager_stacktraces.go b/modules/process/manager_stacktraces.go
new file mode 100644
index 0000000..e260893
--- /dev/null
+++ b/modules/process/manager_stacktraces.go
@@ -0,0 +1,353 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "fmt"
+ "io"
+ "runtime/pprof"
+ "sort"
+ "time"
+
+ "github.com/google/pprof/profile"
+)
+
+// StackEntry is an entry on a stacktrace
+type StackEntry struct {
+ Function string
+ File string
+ Line int
+}
+
+// Label represents a pprof label assigned to goroutine stack
+type Label struct {
+ Name string
+ Value string
+}
+
+// Stack is a stacktrace relating to a goroutine. (Multiple goroutines may have the same stacktrace)
+type Stack struct {
+ Count int64 // Number of goroutines with this stack trace
+ Description string
+ Labels []*Label `json:",omitempty"`
+ Entry []*StackEntry `json:",omitempty"`
+}
+
+// A Process is a combined representation of a Process and a Stacktrace for the goroutines associated with it
+type Process struct {
+ PID IDType
+ ParentPID IDType
+ Description string
+ Start time.Time
+ Type string
+
+ Children []*Process `json:",omitempty"`
+ Stacks []*Stack `json:",omitempty"`
+}
+
+// Processes gets the processes in a thread safe manner
+func (pm *Manager) Processes(flat, noSystem bool) ([]*Process, int) {
+ pm.mutex.Lock()
+ processCount := len(pm.processMap)
+ processes := make([]*Process, 0, len(pm.processMap))
+ if flat {
+ for _, process := range pm.processMap {
+ if noSystem && process.Type == SystemProcessType {
+ continue
+ }
+ processes = append(processes, process.toProcess())
+ }
+ } else {
+ // We need our own processMap
+ processMap := map[IDType]*Process{}
+ for _, internalProcess := range pm.processMap {
+ process, ok := processMap[internalProcess.PID]
+ if !ok {
+ process = internalProcess.toProcess()
+ processMap[process.PID] = process
+ }
+
+ // Check its parent
+ if process.ParentPID == "" {
+ processes = append(processes, process)
+ continue
+ }
+
+ internalParentProcess, ok := pm.processMap[internalProcess.ParentPID]
+ if ok {
+ parentProcess, ok := processMap[process.ParentPID]
+ if !ok {
+ parentProcess = internalParentProcess.toProcess()
+ processMap[parentProcess.PID] = parentProcess
+ }
+ parentProcess.Children = append(parentProcess.Children, process)
+ continue
+ }
+
+ processes = append(processes, process)
+ }
+ }
+ pm.mutex.Unlock()
+
+ if !flat && noSystem {
+ for i := 0; i < len(processes); i++ {
+ process := processes[i]
+ if process.Type != SystemProcessType {
+ continue
+ }
+ processes[len(processes)-1], processes[i] = processes[i], processes[len(processes)-1]
+ processes = append(processes[:len(processes)-1], process.Children...)
+ i--
+ }
+ }
+
+ // Sort by process' start time. Oldest process appears first.
+ sort.Slice(processes, func(i, j int) bool {
+ left, right := processes[i], processes[j]
+
+ return left.Start.Before(right.Start)
+ })
+
+ return processes, processCount
+}
+
+// ProcessStacktraces gets the processes and stacktraces in a thread safe manner
+func (pm *Manager) ProcessStacktraces(flat, noSystem bool) ([]*Process, int, int64, error) {
+ var stacks *profile.Profile
+ var err error
+
+ // We cannot use the pm.ProcessMap here because we will release the mutex ...
+ processMap := map[IDType]*Process{}
+ var processCount int
+
+ // Lock the manager
+ pm.mutex.Lock()
+ processCount = len(pm.processMap)
+
+ // Add a defer to unlock in case there is a panic
+ unlocked := false
+ defer func() {
+ if !unlocked {
+ pm.mutex.Unlock()
+ }
+ }()
+
+ processes := make([]*Process, 0, len(pm.processMap))
+ if flat {
+ for _, internalProcess := range pm.processMap {
+ process := internalProcess.toProcess()
+ processMap[process.PID] = process
+ if noSystem && internalProcess.Type == SystemProcessType {
+ continue
+ }
+ processes = append(processes, process)
+ }
+ } else {
+ for _, internalProcess := range pm.processMap {
+ process, ok := processMap[internalProcess.PID]
+ if !ok {
+ process = internalProcess.toProcess()
+ processMap[process.PID] = process
+ }
+
+ // Check its parent
+ if process.ParentPID == "" {
+ processes = append(processes, process)
+ continue
+ }
+
+ internalParentProcess, ok := pm.processMap[internalProcess.ParentPID]
+ if ok {
+ parentProcess, ok := processMap[process.ParentPID]
+ if !ok {
+ parentProcess = internalParentProcess.toProcess()
+ processMap[parentProcess.PID] = parentProcess
+ }
+ parentProcess.Children = append(parentProcess.Children, process)
+ continue
+ }
+
+ processes = append(processes, process)
+ }
+ }
+
+ // Now from within the lock we need to get the goroutines.
+ // Why? If we release the lock then between between filling the above map and getting
+ // the stacktraces another process could be created which would then look like a dead process below
+ reader, writer := io.Pipe()
+ defer reader.Close()
+ go func() {
+ err := pprof.Lookup("goroutine").WriteTo(writer, 0)
+ _ = writer.CloseWithError(err)
+ }()
+ stacks, err = profile.Parse(reader)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ // Unlock the mutex
+ pm.mutex.Unlock()
+ unlocked = true
+
+ goroutineCount := int64(0)
+
+ // Now walk through the "Sample" slice in the goroutines stack
+ for _, sample := range stacks.Sample {
+ // In the "goroutine" pprof profile each sample represents one or more goroutines
+ // with the same labels and stacktraces.
+
+ // We will represent each goroutine by a `Stack`
+ stack := &Stack{}
+
+ // Add the non-process associated labels from the goroutine sample to the Stack
+ for name, value := range sample.Label {
+ if name == DescriptionPProfLabel || name == PIDPProfLabel || (!flat && name == PPIDPProfLabel) || name == ProcessTypePProfLabel {
+ continue
+ }
+
+ // Labels from the "goroutine" pprof profile only have one value.
+ // This is because the underlying representation is a map[string]string
+ if len(value) != 1 {
+ // Unexpected...
+ return nil, 0, 0, fmt.Errorf("label: %s in goroutine stack with unexpected number of values: %v", name, value)
+ }
+
+ stack.Labels = append(stack.Labels, &Label{Name: name, Value: value[0]})
+ }
+
+ // The number of goroutines that this sample represents is the `stack.Value[0]`
+ stack.Count = sample.Value[0]
+ goroutineCount += stack.Count
+
+ // Now we want to associate this Stack with a Process.
+ var process *Process
+
+ // Try to get the PID from the goroutine labels
+ if pidvalue, ok := sample.Label[PIDPProfLabel]; ok && len(pidvalue) == 1 {
+ pid := IDType(pidvalue[0])
+
+ // Now try to get the process from our map
+ process, ok = processMap[pid]
+ if !ok && pid != "" {
+ // This means that no process has been found in the process map - but there was a process PID
+ // Therefore this goroutine belongs to a dead process and it has escaped control of the process as it
+ // should have died with the process context cancellation.
+
+ // We need to create a dead process holder for this process and label it appropriately
+
+ // get the parent PID
+ ppid := IDType("")
+ if value, ok := sample.Label[PPIDPProfLabel]; ok && len(value) == 1 {
+ ppid = IDType(value[0])
+ }
+
+ // format the description
+ description := "(dead process)"
+ if value, ok := sample.Label[DescriptionPProfLabel]; ok && len(value) == 1 {
+ description = value[0] + " " + description
+ }
+
+ // override the type of the process to "code" but add the old type as a label on the first stack
+ ptype := NoneProcessType
+ if value, ok := sample.Label[ProcessTypePProfLabel]; ok && len(value) == 1 {
+ stack.Labels = append(stack.Labels, &Label{Name: ProcessTypePProfLabel, Value: value[0]})
+ }
+ process = &Process{
+ PID: pid,
+ ParentPID: ppid,
+ Description: description,
+ Type: ptype,
+ }
+
+ // Now add the dead process back to the map and tree so we don't go back through this again.
+ processMap[process.PID] = process
+ added := false
+ if process.ParentPID != "" && !flat {
+ if parent, ok := processMap[process.ParentPID]; ok {
+ parent.Children = append(parent.Children, process)
+ added = true
+ }
+ }
+ if !added {
+ processes = append(processes, process)
+ }
+ }
+ }
+
+ if process == nil {
+ // This means that the sample we're looking has no PID label
+ var ok bool
+ process, ok = processMap[""]
+ if !ok {
+ // this is the first time we've come acrross an unassociated goroutine so create a "process" to hold them
+ process = &Process{
+ Description: "(unassociated)",
+ Type: NoneProcessType,
+ }
+ processMap[process.PID] = process
+ processes = append(processes, process)
+ }
+ }
+
+ // The sample.Location represents a stack trace for this goroutine,
+ // however each Location can represent multiple lines (mostly due to inlining)
+ // so we need to walk the lines too
+ for _, location := range sample.Location {
+ for _, line := range location.Line {
+ entry := &StackEntry{
+ Function: line.Function.Name,
+ File: line.Function.Filename,
+ Line: int(line.Line),
+ }
+ stack.Entry = append(stack.Entry, entry)
+ }
+ }
+
+ // Now we need a short-descriptive name to call the stack trace if when it is folded and
+ // assuming the stack trace has some lines we'll choose the bottom of the stack (i.e. the
+ // initial function that started the stack trace.) The top of the stack is unlikely to
+ // be very helpful as a lot of the time it will be runtime.select or some other call into
+ // a std library.
+ stack.Description = "(unknown)"
+ if len(stack.Entry) > 0 {
+ stack.Description = stack.Entry[len(stack.Entry)-1].Function
+ }
+
+ process.Stacks = append(process.Stacks, stack)
+ }
+
+ // restrict to not show system processes
+ if noSystem {
+ for i := 0; i < len(processes); i++ {
+ process := processes[i]
+ if process.Type != SystemProcessType && process.Type != NoneProcessType {
+ continue
+ }
+ processes[len(processes)-1], processes[i] = processes[i], processes[len(processes)-1]
+ processes = append(processes[:len(processes)-1], process.Children...)
+ i--
+ }
+ }
+
+ // Now finally re-sort the processes. Newest process appears first
+ after := func(processes []*Process) func(i, j int) bool {
+ return func(i, j int) bool {
+ left, right := processes[i], processes[j]
+ return left.Start.After(right.Start)
+ }
+ }
+ sort.Slice(processes, after(processes))
+ if !flat {
+ var sortChildren func(process *Process)
+
+ sortChildren = func(process *Process) {
+ sort.Slice(process.Children, after(process.Children))
+ for _, child := range process.Children {
+ sortChildren(child)
+ }
+ }
+ }
+
+ return processes, processCount, goroutineCount, err
+}
diff --git a/modules/process/manager_test.go b/modules/process/manager_test.go
new file mode 100644
index 0000000..36b2a91
--- /dev/null
+++ b/modules/process/manager_test.go
@@ -0,0 +1,111 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetManager(t *testing.T) {
+ go func() {
+ // test race protection
+ _ = GetManager()
+ }()
+ pm := GetManager()
+ assert.NotNil(t, pm)
+}
+
+func TestManager_AddContext(t *testing.T) {
+ pm := Manager{processMap: make(map[IDType]*process), next: 1}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ p1Ctx, _, finished := pm.AddContext(ctx, "foo")
+ defer finished()
+ assert.NotEmpty(t, GetContext(p1Ctx).GetPID(), "expected to get non-empty pid")
+
+ p2Ctx, _, finished := pm.AddContext(p1Ctx, "bar")
+ defer finished()
+
+ assert.NotEmpty(t, GetContext(p2Ctx).GetPID(), "expected to get non-empty pid")
+
+ assert.NotEqual(t, GetContext(p1Ctx).GetPID(), GetContext(p2Ctx).GetPID(), "expected to get different pids %s == %s", GetContext(p2Ctx).GetPID(), GetContext(p1Ctx).GetPID())
+ assert.Equal(t, GetContext(p1Ctx).GetPID(), GetContext(p2Ctx).GetParent().GetPID(), "expected to get pid %s got %s", GetContext(p1Ctx).GetPID(), GetContext(p2Ctx).GetParent().GetPID())
+}
+
+func TestManager_Cancel(t *testing.T) {
+ pm := Manager{processMap: make(map[IDType]*process), next: 1}
+
+ ctx, _, finished := pm.AddContext(context.Background(), "foo")
+ defer finished()
+
+ pm.Cancel(GetPID(ctx))
+
+ select {
+ case <-ctx.Done():
+ default:
+ assert.FailNow(t, "Cancel should cancel the provided context")
+ }
+ finished()
+
+ ctx, cancel, finished := pm.AddContext(context.Background(), "foo")
+ defer finished()
+
+ cancel()
+
+ select {
+ case <-ctx.Done():
+ default:
+ assert.FailNow(t, "Cancel should cancel the provided context")
+ }
+ finished()
+}
+
+func TestManager_Remove(t *testing.T) {
+ pm := Manager{processMap: make(map[IDType]*process), next: 1}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ p1Ctx, _, finished := pm.AddContext(ctx, "foo")
+ defer finished()
+ assert.NotEmpty(t, GetContext(p1Ctx).GetPID(), "expected to have non-empty PID")
+
+ p2Ctx, _, finished := pm.AddContext(p1Ctx, "bar")
+ defer finished()
+
+ assert.NotEqual(t, GetContext(p1Ctx).GetPID(), GetContext(p2Ctx).GetPID(), "expected to get different pids got %s == %s", GetContext(p2Ctx).GetPID(), GetContext(p1Ctx).GetPID())
+
+ finished()
+
+ _, exists := pm.processMap[GetPID(p2Ctx)]
+ assert.False(t, exists, "PID %d is in the list but shouldn't", GetPID(p2Ctx))
+}
+
+func TestExecTimeoutNever(t *testing.T) {
+ // TODO Investigate how to improve the time elapsed per round.
+ maxLoops := 10
+ for i := 1; i < maxLoops; i++ {
+ _, stderr, err := GetManager().ExecTimeout(5*time.Second, "ExecTimeout", "git", "--version")
+ if err != nil {
+ t.Fatalf("git --version: %v(%s)", err, stderr)
+ }
+ }
+}
+
+func TestExecTimeoutAlways(t *testing.T) {
+ maxLoops := 100
+ for i := 1; i < maxLoops; i++ {
+ _, stderr, err := GetManager().ExecTimeout(100*time.Microsecond, "ExecTimeout", "sleep", "5")
+ // TODO Simplify logging and errors to get precise error type. E.g. checking "if err != context.DeadlineExceeded".
+ if err == nil {
+ t.Fatalf("sleep 5 secs: %v(%s)", err, stderr)
+ }
+ }
+}
diff --git a/modules/process/manager_unix.go b/modules/process/manager_unix.go
new file mode 100644
index 0000000..c5be906
--- /dev/null
+++ b/modules/process/manager_unix.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package process
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// SetSysProcAttribute sets the common SysProcAttrs for commands
+func SetSysProcAttribute(cmd *exec.Cmd) {
+ // When Gitea runs SubProcessA -> SubProcessB and SubProcessA gets killed by context timeout, use setpgid to make sure the sub processes can be reaped instead of leaving defunct(zombie) processes.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+}
diff --git a/modules/process/manager_windows.go b/modules/process/manager_windows.go
new file mode 100644
index 0000000..44a84f2
--- /dev/null
+++ b/modules/process/manager_windows.go
@@ -0,0 +1,15 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package process
+
+import (
+ "os/exec"
+)
+
+// SetSysProcAttribute sets the common SysProcAttrs for commands
+func SetSysProcAttribute(cmd *exec.Cmd) {
+ // Do nothing
+}
diff --git a/modules/process/process.go b/modules/process/process.go
new file mode 100644
index 0000000..06a28c4
--- /dev/null
+++ b/modules/process/process.go
@@ -0,0 +1,38 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package process
+
+import (
+ "context"
+ "time"
+)
+
+var (
+ SystemProcessType = "system"
+ RequestProcessType = "request"
+ NormalProcessType = "normal"
+ NoneProcessType = "none"
+)
+
+// process represents a working process inheriting from Gitea.
+type process struct {
+ PID IDType // Process ID, not system one.
+ ParentPID IDType
+ Description string
+ Start time.Time
+ Cancel context.CancelFunc
+ Type string
+}
+
+// ToProcess converts a process to a externally usable Process
+func (p *process) toProcess() *Process {
+ process := &Process{
+ PID: p.PID,
+ ParentPID: p.ParentPID,
+ Description: p.Description,
+ Start: p.Start,
+ Type: p.Type,
+ }
+ return process
+}
diff --git a/modules/proxy/proxy.go b/modules/proxy/proxy.go
new file mode 100644
index 0000000..1a6bdad
--- /dev/null
+++ b/modules/proxy/proxy.go
@@ -0,0 +1,98 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package proxy
+
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/gobwas/glob"
+)
+
+var (
+ once sync.Once
+ hostMatchers []glob.Glob
+)
+
+// GetProxyURL returns proxy url
+func GetProxyURL() string {
+ if !setting.Proxy.Enabled {
+ return ""
+ }
+
+ if setting.Proxy.ProxyURL == "" {
+ if os.Getenv("http_proxy") != "" {
+ return os.Getenv("http_proxy")
+ }
+ return os.Getenv("https_proxy")
+ }
+ return setting.Proxy.ProxyURL
+}
+
+// Match return true if url needs to be proxied
+func Match(u string) bool {
+ if !setting.Proxy.Enabled {
+ return false
+ }
+
+ // enforce do once
+ Proxy()
+
+ for _, v := range hostMatchers {
+ if v.Match(u) {
+ return true
+ }
+ }
+ return false
+}
+
+// Proxy returns the system proxy
+func Proxy() func(req *http.Request) (*url.URL, error) {
+ if !setting.Proxy.Enabled {
+ return func(req *http.Request) (*url.URL, error) {
+ return nil, nil
+ }
+ }
+ if setting.Proxy.ProxyURL == "" {
+ return http.ProxyFromEnvironment
+ }
+
+ once.Do(func() {
+ for _, h := range setting.Proxy.ProxyHosts {
+ if g, err := glob.Compile(h); err == nil {
+ hostMatchers = append(hostMatchers, g)
+ } else {
+ log.Error("glob.Compile %s failed: %v", h, err)
+ }
+ }
+ })
+
+ return func(req *http.Request) (*url.URL, error) {
+ for _, v := range hostMatchers {
+ if v.Match(req.URL.Host) {
+ return http.ProxyURL(setting.Proxy.ProxyURLFixed)(req)
+ }
+ }
+ return http.ProxyFromEnvironment(req)
+ }
+}
+
+// EnvWithProxy returns os.Environ(), with a https_proxy env, if the given url
+// needs to be proxied.
+func EnvWithProxy(u *url.URL) []string {
+ envs := os.Environ()
+ if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
+ if Match(u.Host) {
+ envs = append(envs, "https_proxy="+GetProxyURL())
+ }
+ }
+
+ return envs
+}
diff --git a/modules/proxyprotocol/conn.go b/modules/proxyprotocol/conn.go
new file mode 100644
index 0000000..f437f13
--- /dev/null
+++ b/modules/proxyprotocol/conn.go
@@ -0,0 +1,505 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package proxyprotocol
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var (
+ // v1Prefix is the string we look for at the start of a connection
+ // to check if this connection is using the proxy protocol
+ v1Prefix = []byte("PROXY ")
+ v1PrefixLen = len(v1Prefix)
+ v2Prefix = []byte("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A")
+ v2PrefixLen = len(v2Prefix)
+)
+
+// Conn is used to wrap and underlying connection which is speaking the
+// Proxy Protocol. RemoteAddr() will return the address of the client
+// instead of the proxy address.
+type Conn struct {
+ bufReader *bufio.Reader
+ conn net.Conn
+ localAddr net.Addr
+ remoteAddr net.Addr
+ once sync.Once
+ proxyHeaderTimeout time.Duration
+ acceptUnknown bool
+}
+
+// NewConn is used to wrap a net.Conn speaking the proxy protocol into
+// a proxyprotocol.Conn
+func NewConn(conn net.Conn, timeout time.Duration) *Conn {
+ pConn := &Conn{
+ bufReader: bufio.NewReader(conn),
+ conn: conn,
+ proxyHeaderTimeout: timeout,
+ }
+ return pConn
+}
+
+// Read reads data from the connection.
+// It will initially read the proxy protocol header.
+// If there is an error parsing the header, it is returned and the socket is closed.
+func (p *Conn) Read(b []byte) (int, error) {
+ if err := p.readProxyHeaderOnce(); err != nil {
+ return 0, err
+ }
+ return p.bufReader.Read(b)
+}
+
+// ReadFrom reads data from a provided reader and copies it to the connection.
+func (p *Conn) ReadFrom(r io.Reader) (int64, error) {
+ if err := p.readProxyHeaderOnce(); err != nil {
+ return 0, err
+ }
+ if rf, ok := p.conn.(io.ReaderFrom); ok {
+ return rf.ReadFrom(r)
+ }
+ return io.Copy(p.conn, r)
+}
+
+// WriteTo reads data from the connection and writes it to the writer.
+// It will initially read the proxy protocol header.
+// If there is an error parsing the header, it is returned and the socket is closed.
+func (p *Conn) WriteTo(w io.Writer) (int64, error) {
+ if err := p.readProxyHeaderOnce(); err != nil {
+ return 0, err
+ }
+ return p.bufReader.WriteTo(w)
+}
+
+// Write writes data to the connection.
+// Write can be made to time out and return an error after a fixed
+// time limit; see SetDeadline and SetWriteDeadline.
+func (p *Conn) Write(b []byte) (int, error) {
+ if err := p.readProxyHeaderOnce(); err != nil {
+ return 0, err
+ }
+ return p.conn.Write(b)
+}
+
+// Close closes the connection.
+// Any blocked Read or Write operations will be unblocked and return errors.
+func (p *Conn) Close() error {
+ return p.conn.Close()
+}
+
+// LocalAddr returns the local network address.
+func (p *Conn) LocalAddr() net.Addr {
+ _ = p.readProxyHeaderOnce()
+ if p.localAddr != nil {
+ return p.localAddr
+ }
+ return p.conn.LocalAddr()
+}
+
+// RemoteAddr returns the address of the client if the proxy
+// protocol is being used, otherwise just returns the address of
+// the socket peer. If there is an error parsing the header, the
+// address of the client is not returned, and the socket is closed.
+// One implication of this is that the call could block if the
+// client is slow. Using a Deadline is recommended if this is called
+// before Read()
+func (p *Conn) RemoteAddr() net.Addr {
+ _ = p.readProxyHeaderOnce()
+ if p.remoteAddr != nil {
+ return p.remoteAddr
+ }
+ return p.conn.RemoteAddr()
+}
+
+// SetDeadline sets the read and write deadlines associated
+// with the connection. It is equivalent to calling both
+// SetReadDeadline and SetWriteDeadline.
+//
+// A deadline is an absolute time after which I/O operations
+// fail instead of blocking. The deadline applies to all future
+// and pending I/O, not just the immediately following call to
+// Read or Write. After a deadline has been exceeded, the
+// connection can be refreshed by setting a deadline in the future.
+//
+// If the deadline is exceeded a call to Read or Write or to other
+// I/O methods will return an error that wraps os.ErrDeadlineExceeded.
+// This can be tested using errors.Is(err, os.ErrDeadlineExceeded).
+// The error's Timeout method will return true, but note that there
+// are other possible errors for which the Timeout method will
+// return true even if the deadline has not been exceeded.
+//
+// An idle timeout can be implemented by repeatedly extending
+// the deadline after successful Read or Write calls.
+//
+// A zero value for t means I/O operations will not time out.
+func (p *Conn) SetDeadline(t time.Time) error {
+ return p.conn.SetDeadline(t)
+}
+
+// SetReadDeadline sets the deadline for future Read calls
+// and any currently-blocked Read call.
+// A zero value for t means Read will not time out.
+func (p *Conn) SetReadDeadline(t time.Time) error {
+ return p.conn.SetReadDeadline(t)
+}
+
+// SetWriteDeadline sets the deadline for future Write calls
+// and any currently-blocked Write call.
+// Even if write times out, it may return n > 0, indicating that
+// some of the data was successfully written.
+// A zero value for t means Write will not time out.
+func (p *Conn) SetWriteDeadline(t time.Time) error {
+ return p.conn.SetWriteDeadline(t)
+}
+
+// readProxyHeaderOnce will ensure that the proxy header has been read
+func (p *Conn) readProxyHeaderOnce() (err error) {
+ p.once.Do(func() {
+ if err = p.readProxyHeader(); err != nil && err != io.EOF {
+ log.Error("Failed to read proxy prefix: %v", err)
+ p.Close()
+ p.bufReader = bufio.NewReader(p.conn)
+ }
+ })
+ return err
+}
+
+func (p *Conn) readProxyHeader() error {
+ if p.proxyHeaderTimeout != 0 {
+ readDeadLine := time.Now().Add(p.proxyHeaderTimeout)
+ _ = p.conn.SetReadDeadline(readDeadLine)
+ defer func() {
+ _ = p.conn.SetReadDeadline(time.Time{})
+ }()
+ }
+
+ inp, err := p.bufReader.Peek(v1PrefixLen)
+ if err != nil {
+ return err
+ }
+
+ if bytes.Equal(inp, v1Prefix) {
+ return p.readV1ProxyHeader()
+ }
+
+ inp, err = p.bufReader.Peek(v2PrefixLen)
+ if err != nil {
+ return err
+ }
+ if bytes.Equal(inp, v2Prefix) {
+ return p.readV2ProxyHeader()
+ }
+
+ return &ErrBadHeader{inp}
+}
+
+func (p *Conn) readV2ProxyHeader() error {
+ // The binary header format starts with a constant 12 bytes block containing the
+ // protocol signature :
+ //
+ // \x0D \x0A \x0D \x0A \x00 \x0D \x0A \x51 \x55 \x49 \x54 \x0A
+ //
+ // Note that this block contains a null byte at the 5th position, so it must not
+ // be handled as a null-terminated string.
+
+ if _, err := p.bufReader.Discard(v2PrefixLen); err != nil {
+ // This shouldn't happen as we have already asserted that there should be enough in the buffer
+ return err
+ }
+
+ // The next byte (the 13th one) is the protocol version and command.
+ version, err := p.bufReader.ReadByte()
+ if err != nil {
+ return err
+ }
+
+ // The 14th byte contains the transport protocol and address family.otocol.
+ familyByte, err := p.bufReader.ReadByte()
+ if err != nil {
+ return err
+ }
+
+ // The 15th and 16th bytes is the address length in bytes in network endian order.
+ var addressLen uint16
+ if err := binary.Read(p.bufReader, binary.BigEndian, &addressLen); err != nil {
+ return err
+ }
+
+ // Now handle the version byte: (14th byte).
+ // The highest four bits contains the version. As of this specification, it must
+ // always be sent as \x2 and the receiver must only accept this value.
+ if version>>4 != 0x2 {
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+
+ // The lowest four bits represents the command :
+ switch version & 0xf {
+ case 0x0:
+ // - \x0 : LOCAL : the connection was established on purpose by the proxy
+ // without being relayed. The connection endpoints are the sender and the
+ // receiver. Such connections exist when the proxy sends health-checks to the
+ // server. The receiver must accept this connection as valid and must use the
+ // real connection endpoints and discard the protocol block including the
+ // family which is ignored.
+
+ // We therefore ignore the 14th, 15th and 16th bytes
+ p.remoteAddr = p.conn.LocalAddr()
+ p.localAddr = p.conn.RemoteAddr()
+ return nil
+ case 0x1:
+ // - \x1 : PROXY : the connection was established on behalf of another node,
+ // and reflects the original connection endpoints. The receiver must then use
+ // the information provided in the protocol block to get original the address.
+ default:
+ // - other values are unassigned and must not be emitted by senders. Receivers
+ // must drop connections presenting unexpected values here.
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+
+ // Now handle the familyByte byte: (15th byte).
+ // The highest 4 bits contain the address family, the lowest 4 bits contain the protocol
+
+ // The address family maps to the original socket family without necessarily
+ // matching the values internally used by the system. It may be one of :
+ //
+ // - 0x0 : AF_UNSPEC : the connection is forwarded for an unknown, unspecified
+ // or unsupported protocol. The sender should use this family when sending
+ // LOCAL commands or when dealing with unsupported protocol families. The
+ // receiver is free to accept the connection anyway and use the real endpoint
+ // addresses or to reject it. The receiver should ignore address information.
+ //
+ // - 0x1 : AF_INET : the forwarded connection uses the AF_INET address family
+ // (IPv4). The addresses are exactly 4 bytes each in network byte order,
+ // followed by transport protocol information (typically ports).
+ //
+ // - 0x2 : AF_INET6 : the forwarded connection uses the AF_INET6 address family
+ // (IPv6). The addresses are exactly 16 bytes each in network byte order,
+ // followed by transport protocol information (typically ports).
+ //
+ // - 0x3 : AF_UNIX : the forwarded connection uses the AF_UNIX address family
+ // (UNIX). The addresses are exactly 108 bytes each.
+ //
+ // - other values are unspecified and must not be emitted in version 2 of this
+ // protocol and must be rejected as invalid by receivers.
+
+ // The transport protocol is specified in the lowest 4 bits of the 14th byte :
+ //
+ // - 0x0 : UNSPEC : the connection is forwarded for an unknown, unspecified
+ // or unsupported protocol. The sender should use this family when sending
+ // LOCAL commands or when dealing with unsupported protocol families. The
+ // receiver is free to accept the connection anyway and use the real endpoint
+ // addresses or to reject it. The receiver should ignore address information.
+ //
+ // - 0x1 : STREAM : the forwarded connection uses a SOCK_STREAM protocol (eg:
+ // TCP or UNIX_STREAM). When used with AF_INET/AF_INET6 (TCP), the addresses
+ // are followed by the source and destination ports represented on 2 bytes
+ // each in network byte order.
+ //
+ // - 0x2 : DGRAM : the forwarded connection uses a SOCK_DGRAM protocol (eg:
+ // UDP or UNIX_DGRAM). When used with AF_INET/AF_INET6 (UDP), the addresses
+ // are followed by the source and destination ports represented on 2 bytes
+ // each in network byte order.
+ //
+ // - other values are unspecified and must not be emitted in version 2 of this
+ // protocol and must be rejected as invalid by receivers.
+
+ if familyByte>>4 == 0x0 || familyByte&0xf == 0x0 {
+ // - hi 0x0 : AF_UNSPEC : the connection is forwarded for an unknown address type
+ // or
+ // - lo 0x0 : UNSPEC : the connection is forwarded for an unspecified protocol
+ if !p.acceptUnknown {
+ p.conn.Close()
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+ p.remoteAddr = p.conn.LocalAddr()
+ p.localAddr = p.conn.RemoteAddr()
+ _, err = p.bufReader.Discard(int(addressLen))
+ return err
+ }
+
+ // other address or protocol
+ if (familyByte>>4) > 0x3 || (familyByte&0xf) > 0x2 {
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+
+ // Handle AF_UNIX addresses
+ if familyByte>>4 == 0x3 {
+ // - \x31 : UNIX stream : the forwarded connection uses SOCK_STREAM over the
+ // AF_UNIX protocol family. Address length is 2*108 = 216 bytes.
+ // - \x32 : UNIX datagram : the forwarded connection uses SOCK_DGRAM over the
+ // AF_UNIX protocol family. Address length is 2*108 = 216 bytes.
+ if addressLen != 216 {
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+ remoteName := make([]byte, 108)
+ localName := make([]byte, 108)
+ if _, err := p.bufReader.Read(remoteName); err != nil {
+ return err
+ }
+ if _, err := p.bufReader.Read(localName); err != nil {
+ return err
+ }
+ protocol := "unix"
+ if familyByte&0xf == 2 {
+ protocol = "unixgram"
+ }
+
+ p.remoteAddr = &net.UnixAddr{
+ Name: string(remoteName),
+ Net: protocol,
+ }
+ p.localAddr = &net.UnixAddr{
+ Name: string(localName),
+ Net: protocol,
+ }
+ return nil
+ }
+
+ var remoteIP []byte
+ var localIP []byte
+ var remotePort uint16
+ var localPort uint16
+
+ if familyByte>>4 == 0x1 {
+ // AF_INET
+ // - \x11 : TCP over IPv4 : the forwarded connection uses TCP over the AF_INET
+ // protocol family. Address length is 2*4 + 2*2 = 12 bytes.
+ // - \x12 : UDP over IPv4 : the forwarded connection uses UDP over the AF_INET
+ // protocol family. Address length is 2*4 + 2*2 = 12 bytes.
+ if addressLen != 12 {
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+
+ remoteIP = make([]byte, 4)
+ localIP = make([]byte, 4)
+ } else {
+ // AF_INET6
+ // - \x21 : TCP over IPv6 : the forwarded connection uses TCP over the AF_INET6
+ // protocol family. Address length is 2*16 + 2*2 = 36 bytes.
+ // - \x22 : UDP over IPv6 : the forwarded connection uses UDP over the AF_INET6
+ // protocol family. Address length is 2*16 + 2*2 = 36 bytes.
+ if addressLen != 36 {
+ return &ErrBadHeader{append(v2Prefix, version, familyByte, uint8(addressLen>>8), uint8(addressLen&0xff))}
+ }
+
+ remoteIP = make([]byte, 16)
+ localIP = make([]byte, 16)
+ }
+
+ if _, err := p.bufReader.Read(remoteIP); err != nil {
+ return err
+ }
+ if _, err := p.bufReader.Read(localIP); err != nil {
+ return err
+ }
+ if err := binary.Read(p.bufReader, binary.BigEndian, &remotePort); err != nil {
+ return err
+ }
+ if err := binary.Read(p.bufReader, binary.BigEndian, &localPort); err != nil {
+ return err
+ }
+
+ if familyByte&0xf == 1 {
+ p.remoteAddr = &net.TCPAddr{
+ IP: remoteIP,
+ Port: int(remotePort),
+ }
+ p.localAddr = &net.TCPAddr{
+ IP: localIP,
+ Port: int(localPort),
+ }
+ } else {
+ p.remoteAddr = &net.UDPAddr{
+ IP: remoteIP,
+ Port: int(remotePort),
+ }
+ p.localAddr = &net.UDPAddr{
+ IP: localIP,
+ Port: int(localPort),
+ }
+ }
+ return nil
+}
+
+func (p *Conn) readV1ProxyHeader() error {
+ // Read until a newline
+ header, err := p.bufReader.ReadString('\n')
+ if err != nil {
+ p.conn.Close()
+ return err
+ }
+
+ if header[len(header)-2] != '\r' {
+ return &ErrBadHeader{[]byte(header)}
+ }
+
+ // Strip the carriage return and new line
+ header = header[:len(header)-2]
+
+ // Split on spaces, should be (PROXY <type> <remote addr> <local addr> <remote port> <local port>)
+ parts := strings.Split(header, " ")
+ if len(parts) < 2 {
+ p.conn.Close()
+ return &ErrBadHeader{[]byte(header)}
+ }
+
+ // Verify the type is known
+ switch parts[1] {
+ case "UNKNOWN":
+ if !p.acceptUnknown || len(parts) != 2 {
+ p.conn.Close()
+ return &ErrBadHeader{[]byte(header)}
+ }
+ p.remoteAddr = p.conn.LocalAddr()
+ p.localAddr = p.conn.RemoteAddr()
+ return nil
+ case "TCP4":
+ case "TCP6":
+ default:
+ p.conn.Close()
+ return &ErrBadAddressType{parts[1]}
+ }
+
+ if len(parts) != 6 {
+ p.conn.Close()
+ return &ErrBadHeader{[]byte(header)}
+ }
+
+ // Parse out the remote address
+ ip := net.ParseIP(parts[2])
+ if ip == nil {
+ p.conn.Close()
+ return &ErrBadRemote{parts[2], parts[4]}
+ }
+ port, err := strconv.Atoi(parts[4])
+ if err != nil {
+ p.conn.Close()
+ return &ErrBadRemote{parts[2], parts[4]}
+ }
+ p.remoteAddr = &net.TCPAddr{IP: ip, Port: port}
+
+ // Parse out the destination address
+ ip = net.ParseIP(parts[3])
+ if ip == nil {
+ p.conn.Close()
+ return &ErrBadLocal{parts[3], parts[5]}
+ }
+ port, err = strconv.Atoi(parts[5])
+ if err != nil {
+ p.conn.Close()
+ return &ErrBadLocal{parts[3], parts[5]}
+ }
+ p.localAddr = &net.TCPAddr{IP: ip, Port: port}
+
+ return nil
+}
diff --git a/modules/proxyprotocol/errors.go b/modules/proxyprotocol/errors.go
new file mode 100644
index 0000000..5439a86
--- /dev/null
+++ b/modules/proxyprotocol/errors.go
@@ -0,0 +1,44 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package proxyprotocol
+
+import "fmt"
+
+// ErrBadHeader is an error demonstrating a bad proxy header
+type ErrBadHeader struct {
+ Header []byte
+}
+
+func (e *ErrBadHeader) Error() string {
+ return fmt.Sprintf("Unexpected proxy header: %v", e.Header)
+}
+
+// ErrBadAddressType is an error demonstrating a bad proxy header with bad Address type
+type ErrBadAddressType struct {
+ Address string
+}
+
+func (e *ErrBadAddressType) Error() string {
+ return fmt.Sprintf("Unexpected proxy header address type: %s", e.Address)
+}
+
+// ErrBadRemote is an error demonstrating a bad proxy header with bad Remote
+type ErrBadRemote struct {
+ IP string
+ Port string
+}
+
+func (e *ErrBadRemote) Error() string {
+ return fmt.Sprintf("Unexpected proxy header remote IP and port: %s %s", e.IP, e.Port)
+}
+
+// ErrBadLocal is an error demonstrating a bad proxy header with bad Local
+type ErrBadLocal struct {
+ IP string
+ Port string
+}
+
+func (e *ErrBadLocal) Error() string {
+ return fmt.Sprintf("Unexpected proxy header local IP and port: %s %s", e.IP, e.Port)
+}
diff --git a/modules/proxyprotocol/listener.go b/modules/proxyprotocol/listener.go
new file mode 100644
index 0000000..ec85c42
--- /dev/null
+++ b/modules/proxyprotocol/listener.go
@@ -0,0 +1,46 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package proxyprotocol
+
+import (
+ "net"
+ "time"
+)
+
+// Listener is used to wrap an underlying listener,
+// whose connections may be using the HAProxy Proxy Protocol (version 1 or 2).
+// If the connection is using the protocol, the RemoteAddr() will return
+// the correct client address.
+//
+// Optionally define ProxyHeaderTimeout to set a maximum time to
+// receive the Proxy Protocol Header. Zero means no timeout.
+type Listener struct {
+ Listener net.Listener
+ ProxyHeaderTimeout time.Duration
+ AcceptUnknown bool // allow PROXY UNKNOWN
+}
+
+// Accept implements the Accept method in the Listener interface
+// it waits for the next call and returns a wrapped Conn.
+func (p *Listener) Accept() (net.Conn, error) {
+ // Get the underlying connection
+ conn, err := p.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+
+ newConn := NewConn(conn, p.ProxyHeaderTimeout)
+ newConn.acceptUnknown = p.AcceptUnknown
+ return newConn, nil
+}
+
+// Close closes the underlying listener.
+func (p *Listener) Close() error {
+ return p.Listener.Close()
+}
+
+// Addr returns the underlying listener's network address.
+func (p *Listener) Addr() net.Addr {
+ return p.Listener.Addr()
+}
diff --git a/modules/proxyprotocol/util.go b/modules/proxyprotocol/util.go
new file mode 100644
index 0000000..a280663
--- /dev/null
+++ b/modules/proxyprotocol/util.go
@@ -0,0 +1,14 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package proxyprotocol
+
+import "io"
+
+var localHeader = append(v2Prefix, '\x20', '\x00', '\x00', '\x00', '\x00')
+
+// WriteLocalHeader will write the ProxyProtocol Header for a local connection to the provided writer
+func WriteLocalHeader(w io.Writer) error {
+ _, err := w.Write(localHeader)
+ return err
+}
diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go
new file mode 100644
index 0000000..32bdf3b
--- /dev/null
+++ b/modules/public/mime_types.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package public
+
+import "strings"
+
+// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of detectWellKnownMimeType
+var wellKnownMimeTypesLower = map[string]string{
+ ".avif": "image/avif",
+ ".css": "text/css; charset=utf-8",
+ ".gif": "image/gif",
+ ".htm": "text/html; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".js": "text/javascript; charset=utf-8",
+ ".json": "application/json",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".pdf": "application/pdf",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".wasm": "application/wasm",
+ ".webp": "image/webp",
+ ".xml": "text/xml; charset=utf-8",
+
+ // well, there are some types missing from the builtin list
+ ".txt": "text/plain; charset=utf-8",
+}
+
+// detectWellKnownMimeType will return the mime-type for a well-known file ext name
+// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
+// mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document).
+// If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers.
+// For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons.
+// detectWellKnownMimeType makes the Content-Type for well-known files stable.
+func detectWellKnownMimeType(ext string) string {
+ ext = strings.ToLower(ext)
+ return wellKnownMimeTypesLower[ext]
+}
diff --git a/modules/public/public.go b/modules/public/public.go
new file mode 100644
index 0000000..abc6b46
--- /dev/null
+++ b/modules/public/public.go
@@ -0,0 +1,118 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package public
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/httpcache"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+func CustomAssets() *assetfs.Layer {
+ return assetfs.Local("custom", setting.CustomPath, "public")
+}
+
+func AssetFS() *assetfs.LayeredFS {
+ return assetfs.Layered(CustomAssets(), BuiltinAssets())
+}
+
+// FileHandlerFunc implements the static handler for serving files in "public" assets
+func FileHandlerFunc() http.HandlerFunc {
+ assetFS := AssetFS()
+ return func(resp http.ResponseWriter, req *http.Request) {
+ if req.Method != "GET" && req.Method != "HEAD" {
+ resp.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ handleRequest(resp, req, assetFS, req.URL.Path)
+ }
+}
+
+// parseAcceptEncoding parse Accept-Encoding: deflate, gzip;q=1.0, *;q=0.5 as compress methods
+func parseAcceptEncoding(val string) container.Set[string] {
+ parts := strings.Split(val, ";")
+ types := make(container.Set[string])
+ for _, v := range strings.Split(parts[0], ",") {
+ types.Add(strings.TrimSpace(v))
+ }
+ return types
+}
+
+// setWellKnownContentType will set the Content-Type if the file is a well-known type.
+// See the comments of detectWellKnownMimeType
+func setWellKnownContentType(w http.ResponseWriter, file string) {
+ mimeType := detectWellKnownMimeType(path.Ext(file))
+ if mimeType != "" {
+ w.Header().Set("Content-Type", mimeType)
+ }
+}
+
+func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) {
+ // actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here
+ f, err := fs.Open(util.PathJoinRelX(file))
+ if err != nil {
+ if os.IsNotExist(err) {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ w.WriteHeader(http.StatusInternalServerError)
+ log.Error("[Static] Open %q failed: %v", file, err)
+ return
+ }
+ defer f.Close()
+
+ fi, err := f.Stat()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ log.Error("[Static] %q exists, but fails to open: %v", file, err)
+ return
+ }
+
+ // need to serve index file? (no at the moment)
+ if fi.IsDir() {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ serveContent(w, req, fi, fi.ModTime(), f)
+}
+
+type GzipBytesProvider interface {
+ GzipBytes() []byte
+}
+
+// serveContent serve http content
+func serveContent(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modtime time.Time, content io.ReadSeeker) {
+ setWellKnownContentType(w, fi.Name())
+
+ encodings := parseAcceptEncoding(req.Header.Get("Accept-Encoding"))
+ if encodings.Contains("gzip") {
+ // try to provide gzip content directly from bindata (provided by vfsgenÛ°CompressedFileInfo)
+ if compressed, ok := fi.(GzipBytesProvider); ok {
+ rdGzip := bytes.NewReader(compressed.GzipBytes())
+ // all gzipped static files (from bindata) are managed by Gitea, so we can make sure every file has the correct ext name
+ // then we can get the correct Content-Type, we do not need to do http.DetectContentType on the decompressed data
+ if w.Header().Get("Content-Type") == "" {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ }
+ w.Header().Set("Content-Encoding", "gzip")
+ httpcache.ServeContentWithCacheControl(w, req, fi.Name(), modtime, rdGzip)
+ return
+ }
+ }
+
+ httpcache.ServeContentWithCacheControl(w, req, fi.Name(), modtime, content)
+ return
+}
diff --git a/modules/public/public_bindata.go b/modules/public/public_bindata.go
new file mode 100644
index 0000000..4878f88
--- /dev/null
+++ b/modules/public/public_bindata.go
@@ -0,0 +1,8 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package public
+
+//go:generate go run ../../build/generate-bindata.go ../../public public bindata.go true
diff --git a/modules/public/public_test.go b/modules/public/public_test.go
new file mode 100644
index 0000000..5e4bf5d
--- /dev/null
+++ b/modules/public/public_test.go
@@ -0,0 +1,34 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package public
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/container"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseAcceptEncoding(t *testing.T) {
+ kases := []struct {
+ Header string
+ Expected container.Set[string]
+ }{
+ {
+ Header: "deflate, gzip;q=1.0, *;q=0.5",
+ Expected: container.SetOf("deflate", "gzip"),
+ },
+ {
+ Header: " gzip, deflate, br",
+ Expected: container.SetOf("deflate", "gzip", "br"),
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.Header, func(t *testing.T) {
+ assert.EqualValues(t, kase.Expected, parseAcceptEncoding(kase.Header))
+ })
+ }
+}
diff --git a/modules/public/serve_dynamic.go b/modules/public/serve_dynamic.go
new file mode 100644
index 0000000..a668b17
--- /dev/null
+++ b/modules/public/serve_dynamic.go
@@ -0,0 +1,15 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package public
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Local("builtin(static)", setting.StaticRootPath, "public")
+}
diff --git a/modules/public/serve_static.go b/modules/public/serve_static.go
new file mode 100644
index 0000000..e790850
--- /dev/null
+++ b/modules/public/serve_static.go
@@ -0,0 +1,24 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package public
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+var _ GzipBytesProvider = (*vfsgenÛ°CompressedFileInfo)(nil)
+
+// GlobalModTime provide a global mod time for embedded asset files
+func GlobalModTime(filename string) time.Time {
+ return timeutil.GetExecutableModTime()
+}
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Bindata("builtin(bindata)", Assets)
+}
diff --git a/modules/queue/backoff.go b/modules/queue/backoff.go
new file mode 100644
index 0000000..cda7233
--- /dev/null
+++ b/modules/queue/backoff.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "time"
+)
+
+const (
+ backoffBegin = 50 * time.Millisecond
+ backoffUpper = 2 * time.Second
+)
+
+type (
+ backoffFuncRetErr[T any] func() (retry bool, ret T, err error)
+ backoffFuncErr func() (retry bool, err error)
+)
+
+func backoffRetErr[T any](ctx context.Context, begin, upper time.Duration, end <-chan time.Time, fn backoffFuncRetErr[T]) (ret T, err error) {
+ d := begin
+ for {
+ // check whether the context has been cancelled or has reached the deadline, return early
+ select {
+ case <-ctx.Done():
+ return ret, ctx.Err()
+ case <-end:
+ return ret, context.DeadlineExceeded
+ default:
+ }
+
+ // call the target function
+ retry, ret, err := fn()
+ if err != nil {
+ return ret, err
+ }
+ if !retry {
+ return ret, nil
+ }
+
+ // wait for a while before retrying, and also respect the context & deadline
+ select {
+ case <-ctx.Done():
+ return ret, ctx.Err()
+ case <-time.After(d):
+ d *= 2
+ if d > upper {
+ d = upper
+ }
+ case <-end:
+ return ret, context.DeadlineExceeded
+ }
+ }
+}
+
+func backoffErr(ctx context.Context, begin, upper time.Duration, end <-chan time.Time, fn backoffFuncErr) error {
+ _, err := backoffRetErr(ctx, begin, upper, end, func() (retry bool, ret any, err error) {
+ retry, err = fn()
+ return retry, nil, err
+ })
+ return err
+}
diff --git a/modules/queue/base.go b/modules/queue/base.go
new file mode 100644
index 0000000..102e79e
--- /dev/null
+++ b/modules/queue/base.go
@@ -0,0 +1,42 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "time"
+)
+
+var pushBlockTime = 5 * time.Second
+
+type baseQueue interface {
+ PushItem(ctx context.Context, data []byte) error
+ PopItem(ctx context.Context) ([]byte, error)
+ HasItem(ctx context.Context, data []byte) (bool, error)
+ Len(ctx context.Context) (int, error)
+ Close() error
+ RemoveAll(ctx context.Context) error
+}
+
+func popItemByChan(ctx context.Context, popItemFn func(ctx context.Context) ([]byte, error)) (chanItem chan []byte, chanErr chan error) {
+ chanItem = make(chan []byte)
+ chanErr = make(chan error)
+ go func() {
+ for {
+ it, err := popItemFn(ctx)
+ if err != nil {
+ close(chanItem)
+ chanErr <- err
+ return
+ }
+ if it == nil {
+ close(chanItem)
+ close(chanErr)
+ return
+ }
+ chanItem <- it
+ }
+ }()
+ return chanItem, chanErr
+}
diff --git a/modules/queue/base_channel.go b/modules/queue/base_channel.go
new file mode 100644
index 0000000..dd8ccb1
--- /dev/null
+++ b/modules/queue/base_channel.go
@@ -0,0 +1,131 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/container"
+)
+
+var errChannelClosed = errors.New("channel is closed")
+
+type baseChannel struct {
+ c chan []byte
+ set container.Set[string]
+ mu sync.Mutex
+
+ isUnique bool
+}
+
+var _ baseQueue = (*baseChannel)(nil)
+
+func newBaseChannelGeneric(cfg *BaseConfig, unique bool) (baseQueue, error) {
+ q := &baseChannel{c: make(chan []byte, cfg.Length), isUnique: unique}
+ if unique {
+ q.set = container.Set[string]{}
+ }
+ return q, nil
+}
+
+func newBaseChannelSimple(cfg *BaseConfig) (baseQueue, error) {
+ return newBaseChannelGeneric(cfg, false)
+}
+
+func newBaseChannelUnique(cfg *BaseConfig) (baseQueue, error) {
+ return newBaseChannelGeneric(cfg, true)
+}
+
+func (q *baseChannel) PushItem(ctx context.Context, data []byte) error {
+ if q.c == nil {
+ return errChannelClosed
+ }
+
+ if q.isUnique {
+ q.mu.Lock()
+ has := q.set.Contains(string(data))
+ q.mu.Unlock()
+ if has {
+ return ErrAlreadyInQueue
+ }
+ }
+
+ select {
+ case q.c <- data:
+ if q.isUnique {
+ q.mu.Lock()
+ q.set.Add(string(data))
+ q.mu.Unlock()
+ }
+ return nil
+ case <-time.After(pushBlockTime):
+ return context.DeadlineExceeded
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (q *baseChannel) PopItem(ctx context.Context) ([]byte, error) {
+ select {
+ case data, ok := <-q.c:
+ if !ok {
+ return nil, errChannelClosed
+ }
+ q.mu.Lock()
+ q.set.Remove(string(data))
+ q.mu.Unlock()
+ return data, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+func (q *baseChannel) HasItem(ctx context.Context, data []byte) (bool, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if !q.isUnique {
+ return false, nil
+ }
+ return q.set.Contains(string(data)), nil
+}
+
+func (q *baseChannel) Len(ctx context.Context) (int, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if q.c == nil {
+ return 0, errChannelClosed
+ }
+
+ return len(q.c), nil
+}
+
+func (q *baseChannel) Close() error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ close(q.c)
+ if q.isUnique {
+ q.set = container.Set[string]{}
+ }
+
+ return nil
+}
+
+func (q *baseChannel) RemoveAll(ctx context.Context) error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ for len(q.c) > 0 {
+ <-q.c
+ }
+
+ if q.isUnique {
+ q.set = container.Set[string]{}
+ }
+ return nil
+}
diff --git a/modules/queue/base_channel_test.go b/modules/queue/base_channel_test.go
new file mode 100644
index 0000000..5d0a2ed
--- /dev/null
+++ b/modules/queue/base_channel_test.go
@@ -0,0 +1,11 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import "testing"
+
+func TestBaseChannel(t *testing.T) {
+ testQueueBasic(t, newBaseChannelSimple, &BaseConfig{ManagedName: "baseChannel", Length: 10}, false)
+ testQueueBasic(t, newBaseChannelUnique, &BaseConfig{ManagedName: "baseChannel", Length: 10}, true)
+}
diff --git a/modules/queue/base_dummy.go b/modules/queue/base_dummy.go
new file mode 100644
index 0000000..7503568
--- /dev/null
+++ b/modules/queue/base_dummy.go
@@ -0,0 +1,38 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import "context"
+
+type baseDummy struct{}
+
+var _ baseQueue = (*baseDummy)(nil)
+
+func newBaseDummy(cfg *BaseConfig, unique bool) (baseQueue, error) {
+ return &baseDummy{}, nil
+}
+
+func (q *baseDummy) PushItem(ctx context.Context, data []byte) error {
+ return nil
+}
+
+func (q *baseDummy) PopItem(ctx context.Context) ([]byte, error) {
+ return nil, nil
+}
+
+func (q *baseDummy) Len(ctx context.Context) (int, error) {
+ return 0, nil
+}
+
+func (q *baseDummy) HasItem(ctx context.Context, data []byte) (bool, error) {
+ return false, nil
+}
+
+func (q *baseDummy) Close() error {
+ return nil
+}
+
+func (q *baseDummy) RemoveAll(ctx context.Context) error {
+ return nil
+}
diff --git a/modules/queue/base_levelqueue.go b/modules/queue/base_levelqueue.go
new file mode 100644
index 0000000..efc57c9
--- /dev/null
+++ b/modules/queue/base_levelqueue.go
@@ -0,0 +1,83 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/nosql"
+ "code.gitea.io/gitea/modules/queue/lqinternal"
+
+ "gitea.com/lunny/levelqueue"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+type baseLevelQueue struct {
+ internal atomic.Pointer[levelqueue.Queue]
+
+ conn string
+ cfg *BaseConfig
+ db *leveldb.DB
+}
+
+var _ baseQueue = (*baseLevelQueue)(nil)
+
+func newBaseLevelQueueGeneric(cfg *BaseConfig, unique bool) (baseQueue, error) {
+ if unique {
+ return newBaseLevelQueueUnique(cfg)
+ }
+ return newBaseLevelQueueSimple(cfg)
+}
+
+func newBaseLevelQueueSimple(cfg *BaseConfig) (baseQueue, error) {
+ conn, db, err := prepareLevelDB(cfg)
+ if err != nil {
+ return nil, err
+ }
+ q := &baseLevelQueue{conn: conn, cfg: cfg, db: db}
+ lq, err := levelqueue.NewQueue(db, []byte(cfg.QueueFullName), false)
+ if err != nil {
+ return nil, err
+ }
+ q.internal.Store(lq)
+ return q, nil
+}
+
+func (q *baseLevelQueue) PushItem(ctx context.Context, data []byte) error {
+ c := baseLevelQueueCommon(q.cfg, nil, func() baseLevelQueuePushPoper { return q.internal.Load() })
+ return c.PushItem(ctx, data)
+}
+
+func (q *baseLevelQueue) PopItem(ctx context.Context) ([]byte, error) {
+ c := baseLevelQueueCommon(q.cfg, nil, func() baseLevelQueuePushPoper { return q.internal.Load() })
+ return c.PopItem(ctx)
+}
+
+func (q *baseLevelQueue) HasItem(ctx context.Context, data []byte) (bool, error) {
+ return false, nil
+}
+
+func (q *baseLevelQueue) Len(ctx context.Context) (int, error) {
+ return int(q.internal.Load().Len()), nil
+}
+
+func (q *baseLevelQueue) Close() error {
+ err := q.internal.Load().Close()
+ _ = nosql.GetManager().CloseLevelDB(q.conn)
+ q.db = nil // the db is not managed by us, it's managed by the nosql manager
+ return err
+}
+
+func (q *baseLevelQueue) RemoveAll(ctx context.Context) error {
+ lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.QueueFullName))
+ lq, err := levelqueue.NewQueue(q.db, []byte(q.cfg.QueueFullName), false)
+ if err != nil {
+ return err
+ }
+ old := q.internal.Load()
+ q.internal.Store(lq)
+ _ = old.Close() // Not ideal for concurrency. Luckily, the levelqueue only sets its db=nil because it doesn't manage the db, so far so good
+ return nil
+}
diff --git a/modules/queue/base_levelqueue_common.go b/modules/queue/base_levelqueue_common.go
new file mode 100644
index 0000000..78d3b85
--- /dev/null
+++ b/modules/queue/base_levelqueue_common.go
@@ -0,0 +1,93 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/nosql"
+
+ "gitea.com/lunny/levelqueue"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+// baseLevelQueuePushPoper is the common interface for levelqueue.Queue and levelqueue.UniqueQueue
+type baseLevelQueuePushPoper interface {
+ RPush(data []byte) error
+ LPop() ([]byte, error)
+ Len() int64
+}
+
+type baseLevelQueueCommonImpl struct {
+ length int
+ internalFunc func() baseLevelQueuePushPoper
+ mu *sync.Mutex
+}
+
+func (q *baseLevelQueueCommonImpl) PushItem(ctx context.Context, data []byte) error {
+ return backoffErr(ctx, backoffBegin, backoffUpper, time.After(pushBlockTime), func() (retry bool, err error) {
+ if q.mu != nil {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ }
+
+ cnt := int(q.internalFunc().Len())
+ if cnt >= q.length {
+ return true, nil
+ }
+ retry, err = false, q.internalFunc().RPush(data)
+ if err == levelqueue.ErrAlreadyInQueue {
+ err = ErrAlreadyInQueue
+ }
+ return retry, err
+ })
+}
+
+func (q *baseLevelQueueCommonImpl) PopItem(ctx context.Context) ([]byte, error) {
+ return backoffRetErr(ctx, backoffBegin, backoffUpper, infiniteTimerC, func() (retry bool, data []byte, err error) {
+ if q.mu != nil {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ }
+
+ data, err = q.internalFunc().LPop()
+ if err == levelqueue.ErrNotFound {
+ return true, nil, nil
+ }
+ if err != nil {
+ return false, nil, err
+ }
+ return false, data, nil
+ })
+}
+
+func baseLevelQueueCommon(cfg *BaseConfig, mu *sync.Mutex, internalFunc func() baseLevelQueuePushPoper) *baseLevelQueueCommonImpl {
+ return &baseLevelQueueCommonImpl{length: cfg.Length, mu: mu, internalFunc: internalFunc}
+}
+
+func prepareLevelDB(cfg *BaseConfig) (conn string, db *leveldb.DB, err error) {
+ if cfg.ConnStr == "" { // use data dir as conn str
+ if !filepath.IsAbs(cfg.DataFullDir) {
+ return "", nil, fmt.Errorf("invalid leveldb data dir (not absolute): %q", cfg.DataFullDir)
+ }
+ conn = cfg.DataFullDir
+ } else {
+ if !strings.HasPrefix(cfg.ConnStr, "leveldb://") {
+ return "", nil, fmt.Errorf("invalid leveldb connection string: %q", cfg.ConnStr)
+ }
+ conn = cfg.ConnStr
+ }
+ for i := 0; i < 10; i++ {
+ if db, err = nosql.GetManager().GetLevelDB(conn); err == nil {
+ break
+ }
+ time.Sleep(1 * time.Second)
+ }
+ return conn, db, err
+}
diff --git a/modules/queue/base_levelqueue_test.go b/modules/queue/base_levelqueue_test.go
new file mode 100644
index 0000000..b65b570
--- /dev/null
+++ b/modules/queue/base_levelqueue_test.go
@@ -0,0 +1,78 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/queue/lqinternal"
+ "code.gitea.io/gitea/modules/setting"
+
+ "gitea.com/lunny/levelqueue"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+func TestBaseLevelDB(t *testing.T) {
+ _, err := newBaseLevelQueueGeneric(&BaseConfig{ConnStr: "redis://"}, false)
+ require.ErrorContains(t, err, "invalid leveldb connection string")
+
+ _, err = newBaseLevelQueueGeneric(&BaseConfig{DataFullDir: "relative"}, false)
+ require.ErrorContains(t, err, "invalid leveldb data dir")
+
+ testQueueBasic(t, newBaseLevelQueueSimple, toBaseConfig("baseLevelQueue", setting.QueueSettings{Datadir: t.TempDir() + "/queue-test", Length: 10}), false)
+ testQueueBasic(t, newBaseLevelQueueUnique, toBaseConfig("baseLevelQueueUnique", setting.QueueSettings{ConnStr: "leveldb://" + t.TempDir() + "/queue-test", Length: 10}), true)
+}
+
+func TestCorruptedLevelQueue(t *testing.T) {
+ // sometimes the levelqueue could be in a corrupted state, this test is to make sure it can recover from it
+ dbDir := t.TempDir() + "/levelqueue-test"
+ db, err := leveldb.OpenFile(dbDir, nil)
+ require.NoError(t, err)
+
+ defer db.Close()
+
+ require.NoError(t, db.Put([]byte("other-key"), []byte("other-value"), nil))
+
+ nameQueuePrefix := []byte("queue_name")
+ nameSetPrefix := []byte("set_name")
+ lq, err := levelqueue.NewUniqueQueue(db, nameQueuePrefix, nameSetPrefix, false)
+ require.NoError(t, err)
+ require.NoError(t, lq.RPush([]byte("item-1")))
+
+ itemKey := lqinternal.QueueItemKeyBytes(nameQueuePrefix, 1)
+ itemValue, err := db.Get(itemKey, nil)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("item-1"), itemValue)
+
+ // there should be 5 keys in db: queue low, queue high, 1 queue item, 1 set item, and "other-key"
+ keys := lqinternal.ListLevelQueueKeys(db)
+ assert.Len(t, keys, 5)
+
+ // delete the queue item key, to corrupt the queue
+ require.NoError(t, db.Delete(itemKey, nil))
+ // now the queue is corrupted, it never works again
+ _, err = lq.LPop()
+ require.ErrorIs(t, err, levelqueue.ErrNotFound)
+ require.NoError(t, lq.Close())
+
+ // remove all the queue related keys to reset the queue
+ lqinternal.RemoveLevelQueueKeys(db, nameQueuePrefix)
+ lqinternal.RemoveLevelQueueKeys(db, nameSetPrefix)
+ // now there should be only 1 key in db: "other-key"
+ keys = lqinternal.ListLevelQueueKeys(db)
+ assert.Len(t, keys, 1)
+ assert.Equal(t, []byte("other-key"), keys[0])
+
+ // re-create a queue from db
+ lq, err = levelqueue.NewUniqueQueue(db, nameQueuePrefix, nameSetPrefix, false)
+ require.NoError(t, err)
+ require.NoError(t, lq.RPush([]byte("item-new-1")))
+ // now the queue works again
+ itemValue, err = lq.LPop()
+ require.NoError(t, err)
+ assert.Equal(t, []byte("item-new-1"), itemValue)
+ require.NoError(t, lq.Close())
+}
diff --git a/modules/queue/base_levelqueue_unique.go b/modules/queue/base_levelqueue_unique.go
new file mode 100644
index 0000000..968a4e9
--- /dev/null
+++ b/modules/queue/base_levelqueue_unique.go
@@ -0,0 +1,88 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/nosql"
+ "code.gitea.io/gitea/modules/queue/lqinternal"
+
+ "gitea.com/lunny/levelqueue"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+type baseLevelQueueUnique struct {
+ internal atomic.Pointer[levelqueue.UniqueQueue]
+
+ conn string
+ cfg *BaseConfig
+ db *leveldb.DB
+
+ mu sync.Mutex // the levelqueue.UniqueQueue is not thread-safe, there is no mutex protecting the underlying queue&set together
+}
+
+var _ baseQueue = (*baseLevelQueueUnique)(nil)
+
+func newBaseLevelQueueUnique(cfg *BaseConfig) (baseQueue, error) {
+ conn, db, err := prepareLevelDB(cfg)
+ if err != nil {
+ return nil, err
+ }
+ q := &baseLevelQueueUnique{conn: conn, cfg: cfg, db: db}
+ lq, err := levelqueue.NewUniqueQueue(db, []byte(cfg.QueueFullName), []byte(cfg.SetFullName), false)
+ if err != nil {
+ return nil, err
+ }
+ q.internal.Store(lq)
+ return q, nil
+}
+
+func (q *baseLevelQueueUnique) PushItem(ctx context.Context, data []byte) error {
+ c := baseLevelQueueCommon(q.cfg, &q.mu, func() baseLevelQueuePushPoper { return q.internal.Load() })
+ return c.PushItem(ctx, data)
+}
+
+func (q *baseLevelQueueUnique) PopItem(ctx context.Context) ([]byte, error) {
+ c := baseLevelQueueCommon(q.cfg, &q.mu, func() baseLevelQueuePushPoper { return q.internal.Load() })
+ return c.PopItem(ctx)
+}
+
+func (q *baseLevelQueueUnique) HasItem(ctx context.Context, data []byte) (bool, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ return q.internal.Load().Has(data)
+}
+
+func (q *baseLevelQueueUnique) Len(ctx context.Context) (int, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ return int(q.internal.Load().Len()), nil
+}
+
+func (q *baseLevelQueueUnique) Close() error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ err := q.internal.Load().Close()
+ q.db = nil // the db is not managed by us, it's managed by the nosql manager
+ _ = nosql.GetManager().CloseLevelDB(q.conn)
+ return err
+}
+
+func (q *baseLevelQueueUnique) RemoveAll(ctx context.Context) error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.QueueFullName))
+ lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.SetFullName))
+ lq, err := levelqueue.NewUniqueQueue(q.db, []byte(q.cfg.QueueFullName), []byte(q.cfg.SetFullName), false)
+ if err != nil {
+ return err
+ }
+ old := q.internal.Load()
+ q.internal.Store(lq)
+ _ = old.Close() // Not ideal for concurrency. Luckily, the levelqueue only sets its db=nil because it doesn't manage the db, so far so good
+ return nil
+}
diff --git a/modules/queue/base_redis.go b/modules/queue/base_redis.go
new file mode 100644
index 0000000..62df30f
--- /dev/null
+++ b/modules/queue/base_redis.go
@@ -0,0 +1,162 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/nosql"
+
+ "github.com/redis/go-redis/v9"
+)
+
+type baseRedis struct {
+ client nosql.RedisClient
+ isUnique bool
+ cfg *BaseConfig
+ prefix string
+
+ mu sync.Mutex // the old implementation is not thread-safe, the queue operation and set operation should be protected together
+}
+
+var _ baseQueue = (*baseRedis)(nil)
+
+func newBaseRedisGeneric(cfg *BaseConfig, unique bool, client nosql.RedisClient) (baseQueue, error) {
+ if client == nil {
+ client = nosql.GetManager().GetRedisClient(cfg.ConnStr)
+ }
+
+ prefix := ""
+ uri := nosql.ToRedisURI(cfg.ConnStr)
+
+ for key, value := range uri.Query() {
+ switch key {
+ case "prefix":
+ if len(value) > 0 {
+ prefix = value[0]
+
+ // As we are not checking any other values, if we found this one, we can
+ // exit from the loop.
+ // If a new key check is required, remove this break.
+ break
+ }
+ }
+ }
+
+ var err error
+ for i := 0; i < 10; i++ {
+ err = client.Ping(graceful.GetManager().ShutdownContext()).Err()
+ if err == nil {
+ break
+ }
+ log.Warn("Redis is not ready, waiting for 1 second to retry: %v", err)
+ time.Sleep(time.Second)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &baseRedis{cfg: cfg, client: client, isUnique: unique, prefix: prefix}, nil
+}
+
+func newBaseRedisSimple(cfg *BaseConfig) (baseQueue, error) {
+ return newBaseRedisGeneric(cfg, false, nil)
+}
+
+func newBaseRedisUnique(cfg *BaseConfig) (baseQueue, error) {
+ return newBaseRedisGeneric(cfg, true, nil)
+}
+
+func (q *baseRedis) prefixedName(name string) string {
+ return q.prefix + name
+}
+
+func (q *baseRedis) PushItem(ctx context.Context, data []byte) error {
+ return backoffErr(ctx, backoffBegin, backoffUpper, time.After(pushBlockTime), func() (retry bool, err error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ cnt, err := q.client.LLen(ctx, q.prefixedName(q.cfg.QueueFullName)).Result()
+ if err != nil {
+ return false, err
+ }
+ if int(cnt) >= q.cfg.Length {
+ return true, nil
+ }
+
+ if q.isUnique {
+ added, err := q.client.SAdd(ctx, q.prefixedName(q.cfg.SetFullName), data).Result()
+ if err != nil {
+ return false, err
+ }
+ if added == 0 {
+ return false, ErrAlreadyInQueue
+ }
+ }
+ return false, q.client.RPush(ctx, q.prefixedName(q.cfg.QueueFullName), data).Err()
+ })
+}
+
+func (q *baseRedis) PopItem(ctx context.Context) ([]byte, error) {
+ return backoffRetErr(ctx, backoffBegin, backoffUpper, infiniteTimerC, func() (retry bool, data []byte, err error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ data, err = q.client.LPop(ctx, q.prefixedName(q.cfg.QueueFullName)).Bytes()
+ if err == redis.Nil {
+ return true, nil, nil
+ }
+ if err != nil {
+ return true, nil, nil
+ }
+ if q.isUnique {
+ // the data has been popped, even if there is any error we can't do anything
+ _ = q.client.SRem(ctx, q.prefixedName(q.cfg.SetFullName), data).Err()
+ }
+ return false, data, err
+ })
+}
+
+func (q *baseRedis) HasItem(ctx context.Context, data []byte) (bool, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if !q.isUnique {
+ return false, nil
+ }
+ return q.client.SIsMember(ctx, q.prefixedName(q.cfg.SetFullName), data).Result()
+}
+
+func (q *baseRedis) Len(ctx context.Context) (int, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ cnt, err := q.client.LLen(ctx, q.prefixedName(q.cfg.QueueFullName)).Result()
+ return int(cnt), err
+}
+
+func (q *baseRedis) Close() error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ return q.client.Close()
+}
+
+func (q *baseRedis) RemoveAll(ctx context.Context) error {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ c1 := q.client.Del(ctx, q.prefixedName(q.cfg.QueueFullName))
+ // the "set" must be cleared after the "list" because there is no transaction.
+ // it's better to have duplicate items than losing items.
+ c2 := q.client.Del(ctx, q.prefixedName(q.cfg.SetFullName))
+ if c1.Err() != nil {
+ return c1.Err()
+ }
+ if c2.Err() != nil {
+ return c2.Err()
+ }
+ return nil // actually, checking errors doesn't make sense here because the state could be out-of-sync
+}
diff --git a/modules/queue/base_redis_test.go b/modules/queue/base_redis_test.go
new file mode 100644
index 0000000..fa1700d
--- /dev/null
+++ b/modules/queue/base_redis_test.go
@@ -0,0 +1,138 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/modules/queue/mock"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/stretchr/testify/suite"
+ "go.uber.org/mock/gomock"
+)
+
+type baseRedisUnitTestSuite struct {
+ suite.Suite
+
+ mockController *gomock.Controller
+}
+
+func TestBaseRedis(t *testing.T) {
+ suite.Run(t, &baseRedisUnitTestSuite{})
+}
+
+func (suite *baseRedisUnitTestSuite) SetupSuite() {
+ suite.mockController = gomock.NewController(suite.T())
+}
+
+func (suite *baseRedisUnitTestSuite) TestBasic() {
+ queueName := "test-queue"
+ testCases := []struct {
+ Name string
+ ConnectionString string
+ QueueName string
+ Unique bool
+ }{
+ {
+ Name: "unique",
+ ConnectionString: "redis://127.0.0.1/0",
+ QueueName: queueName,
+ Unique: true,
+ },
+ {
+ Name: "non-unique",
+ ConnectionString: "redis://127.0.0.1/0",
+ QueueName: queueName,
+ Unique: false,
+ },
+ {
+ Name: "unique with prefix",
+ ConnectionString: "redis://127.0.0.1/0?prefix=forgejo:queue:",
+ QueueName: "forgejo:queue:" + queueName,
+ Unique: true,
+ },
+ {
+ Name: "non-unique with prefix",
+ ConnectionString: "redis://127.0.0.1/0?prefix=forgejo:queue:",
+ QueueName: "forgejo:queue:" + queueName,
+ Unique: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ suite.Run(testCase.Name, func() {
+ queueSettings := setting.QueueSettings{
+ Length: 10,
+ ConnStr: testCase.ConnectionString,
+ }
+
+ // Configure expectations.
+ mockRedisStore := mock.NewInMemoryMockRedis()
+ redisClient := mock.NewMockRedisClient(suite.mockController)
+
+ redisClient.EXPECT().
+ Ping(gomock.Any()).
+ Times(1).
+ Return(&redis.StatusCmd{})
+ redisClient.EXPECT().
+ LLen(gomock.Any(), testCase.QueueName).
+ Times(1).
+ DoAndReturn(mockRedisStore.LLen)
+ redisClient.EXPECT().
+ LPop(gomock.Any(), testCase.QueueName).
+ Times(1).
+ DoAndReturn(mockRedisStore.LPop)
+ redisClient.EXPECT().
+ RPush(gomock.Any(), testCase.QueueName, gomock.Any()).
+ Times(1).
+ DoAndReturn(mockRedisStore.RPush)
+
+ if testCase.Unique {
+ redisClient.EXPECT().
+ SAdd(gomock.Any(), testCase.QueueName+"_unique", gomock.Any()).
+ Times(1).
+ DoAndReturn(mockRedisStore.SAdd)
+ redisClient.EXPECT().
+ SRem(gomock.Any(), testCase.QueueName+"_unique", gomock.Any()).
+ Times(1).
+ DoAndReturn(mockRedisStore.SRem)
+ redisClient.EXPECT().
+ SIsMember(gomock.Any(), testCase.QueueName+"_unique", gomock.Any()).
+ Times(2).
+ DoAndReturn(mockRedisStore.SIsMember)
+ }
+
+ client, err := newBaseRedisGeneric(
+ toBaseConfig(queueName, queueSettings),
+ testCase.Unique,
+ redisClient,
+ )
+ suite.Require().NoError(err)
+
+ ctx := context.Background()
+ expectedContent := []byte("test")
+
+ suite.Require().NoError(client.PushItem(ctx, expectedContent))
+
+ found, err := client.HasItem(ctx, expectedContent)
+ suite.Require().NoError(err)
+ if testCase.Unique {
+ suite.True(found)
+ } else {
+ suite.False(found)
+ }
+
+ found, err = client.HasItem(ctx, []byte("not found content"))
+ suite.Require().NoError(err)
+ suite.False(found)
+
+ content, err := client.PopItem(ctx)
+ suite.Require().NoError(err)
+ suite.Equal(expectedContent, content)
+ })
+ }
+}
diff --git a/modules/queue/base_redis_with_server_test.go b/modules/queue/base_redis_with_server_test.go
new file mode 100644
index 0000000..b73404f
--- /dev/null
+++ b/modules/queue/base_redis_with_server_test.go
@@ -0,0 +1,133 @@
+package queue
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/nosql"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/suite"
+)
+
+const defaultTestRedisServer = "127.0.0.1:6379"
+
+type baseRedisWithServerTestSuite struct {
+ suite.Suite
+}
+
+func TestBaseRedisWithServer(t *testing.T) {
+ suite.Run(t, &baseRedisWithServerTestSuite{})
+}
+
+func (suite *baseRedisWithServerTestSuite) TestNormal() {
+ redisAddress := "redis://" + suite.testRedisHost() + "/0"
+ queueSettings := setting.QueueSettings{
+ Length: 10,
+ ConnStr: redisAddress,
+ }
+
+ redisServer, accessible := suite.startRedisServer(redisAddress)
+
+ // If it's accessible, but redisServer command is nil, that means we are using
+ // an already running redis server.
+ if redisServer == nil && !accessible {
+ suite.T().Skip("redis-server not found in Forgejo test yet")
+
+ return
+ }
+
+ defer func() {
+ if redisServer != nil {
+ _ = redisServer.Process.Signal(os.Interrupt)
+ _ = redisServer.Wait()
+ }
+ }()
+
+ testQueueBasic(suite.T(), newBaseRedisSimple, toBaseConfig("baseRedis", queueSettings), false)
+ testQueueBasic(suite.T(), newBaseRedisUnique, toBaseConfig("baseRedisUnique", queueSettings), true)
+}
+
+func (suite *baseRedisWithServerTestSuite) TestWithPrefix() {
+ redisAddress := "redis://" + suite.testRedisHost() + "/0?prefix=forgejo:queue:"
+ queueSettings := setting.QueueSettings{
+ Length: 10,
+ ConnStr: redisAddress,
+ }
+
+ redisServer, accessible := suite.startRedisServer(redisAddress)
+
+ // If it's accessible, but redisServer command is nil, that means we are using
+ // an already running redis server.
+ if redisServer == nil && !accessible {
+ suite.T().Skip("redis-server not found in Forgejo test yet")
+
+ return
+ }
+
+ defer func() {
+ if redisServer != nil {
+ _ = redisServer.Process.Signal(os.Interrupt)
+ _ = redisServer.Wait()
+ }
+ }()
+
+ testQueueBasic(suite.T(), newBaseRedisSimple, toBaseConfig("baseRedis", queueSettings), false)
+ testQueueBasic(suite.T(), newBaseRedisUnique, toBaseConfig("baseRedisUnique", queueSettings), true)
+}
+
+func (suite *baseRedisWithServerTestSuite) startRedisServer(address string) (*exec.Cmd, bool) {
+ var redisServer *exec.Cmd
+
+ if !suite.waitRedisReady(address, 0) {
+ redisServerProg, err := exec.LookPath("redis-server")
+ if err != nil {
+ return nil, false
+ }
+ redisServer = &exec.Cmd{
+ Path: redisServerProg,
+ Args: []string{redisServerProg, "--bind", "127.0.0.1", "--port", "6379"},
+ Dir: suite.T().TempDir(),
+ Stdin: os.Stdin,
+ Stdout: os.Stdout,
+ Stderr: os.Stderr,
+ }
+
+ suite.Require().NoError(redisServer.Start())
+
+ if !suite.True(suite.waitRedisReady(address, 5*time.Second), "start redis-server") {
+ // Return with redis server even if it's not available. It was started,
+ // even if it's not reachable for any reasons, it's still started, the
+ // parent will close it.
+ return redisServer, false
+ }
+ }
+
+ return redisServer, true
+}
+
+func (suite *baseRedisWithServerTestSuite) waitRedisReady(conn string, dur time.Duration) (ready bool) {
+ ctxTimed, cancel := context.WithTimeout(context.Background(), time.Second*5)
+ defer cancel()
+ for t := time.Now(); ; time.Sleep(50 * time.Millisecond) {
+ ret := nosql.GetManager().GetRedisClient(conn).Ping(ctxTimed)
+ if ret.Err() == nil {
+ return true
+ }
+ if time.Since(t) > dur {
+ return false
+ }
+ }
+}
+
+func (suite *baseRedisWithServerTestSuite) testRedisHost() string {
+ value := os.Getenv("TEST_REDIS_SERVER")
+ if value != "" {
+ return value
+ }
+
+ return defaultTestRedisServer
+}
diff --git a/modules/queue/base_test.go b/modules/queue/base_test.go
new file mode 100644
index 0000000..a5600fe
--- /dev/null
+++ b/modules/queue/base_test.go
@@ -0,0 +1,141 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testQueueBasic(t *testing.T, newFn func(cfg *BaseConfig) (baseQueue, error), cfg *BaseConfig, isUnique bool) {
+ t.Run(fmt.Sprintf("testQueueBasic-%s-unique:%v", cfg.ManagedName, isUnique), func(t *testing.T) {
+ q, err := newFn(cfg)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ _ = q.RemoveAll(ctx)
+ cnt, err := q.Len(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, cnt)
+
+ // push the first item
+ err = q.PushItem(ctx, []byte("foo"))
+ require.NoError(t, err)
+
+ cnt, err = q.Len(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, cnt)
+
+ // push a duplicate item
+ err = q.PushItem(ctx, []byte("foo"))
+ if !isUnique {
+ require.NoError(t, err)
+ } else {
+ require.ErrorIs(t, err, ErrAlreadyInQueue)
+ }
+
+ // check the duplicate item
+ cnt, err = q.Len(ctx)
+ require.NoError(t, err)
+ has, err := q.HasItem(ctx, []byte("foo"))
+ require.NoError(t, err)
+ if !isUnique {
+ assert.EqualValues(t, 2, cnt)
+ assert.False(t, has) // non-unique queues don't check for duplicates
+ } else {
+ assert.EqualValues(t, 1, cnt)
+ assert.True(t, has)
+ }
+
+ // push another item
+ err = q.PushItem(ctx, []byte("bar"))
+ require.NoError(t, err)
+
+ // pop the first item (and the duplicate if non-unique)
+ it, err := q.PopItem(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, "foo", string(it))
+
+ if !isUnique {
+ it, err = q.PopItem(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, "foo", string(it))
+ }
+
+ // pop another item
+ it, err = q.PopItem(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, "bar", string(it))
+
+ // pop an empty queue (timeout, cancel)
+ ctxTimed, cancel := context.WithTimeout(ctx, 10*time.Millisecond)
+ it, err = q.PopItem(ctxTimed)
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ assert.Nil(t, it)
+ cancel()
+
+ ctxTimed, cancel = context.WithTimeout(ctx, 10*time.Millisecond)
+ cancel()
+ it, err = q.PopItem(ctxTimed)
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Nil(t, it)
+
+ // test blocking push if queue is full
+ for i := 0; i < cfg.Length; i++ {
+ err = q.PushItem(ctx, []byte(fmt.Sprintf("item-%d", i)))
+ require.NoError(t, err)
+ }
+ ctxTimed, cancel = context.WithTimeout(ctx, 10*time.Millisecond)
+ err = q.PushItem(ctxTimed, []byte("item-full"))
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ cancel()
+
+ // test blocking push if queue is full (with custom pushBlockTime)
+ oldPushBlockTime := pushBlockTime
+ timeStart := time.Now()
+ pushBlockTime = 30 * time.Millisecond
+ err = q.PushItem(ctx, []byte("item-full"))
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ assert.GreaterOrEqual(t, time.Since(timeStart), pushBlockTime*2/3)
+ pushBlockTime = oldPushBlockTime
+
+ // remove all
+ cnt, err = q.Len(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, cfg.Length, cnt)
+
+ _ = q.RemoveAll(ctx)
+
+ cnt, err = q.Len(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, cnt)
+ })
+}
+
+func TestBaseDummy(t *testing.T) {
+ q, err := newBaseDummy(&BaseConfig{}, true)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ require.NoError(t, q.PushItem(ctx, []byte("foo")))
+
+ cnt, err := q.Len(ctx)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, cnt)
+
+ has, err := q.HasItem(ctx, []byte("foo"))
+ require.NoError(t, err)
+ assert.False(t, has)
+
+ it, err := q.PopItem(ctx)
+ require.NoError(t, err)
+ assert.Nil(t, it)
+
+ require.NoError(t, q.RemoveAll(ctx))
+}
diff --git a/modules/queue/config.go b/modules/queue/config.go
new file mode 100644
index 0000000..c5bc16b
--- /dev/null
+++ b/modules/queue/config.go
@@ -0,0 +1,36 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type BaseConfig struct {
+ ManagedName string
+ DataFullDir string // the caller must prepare an absolute path
+
+ ConnStr string
+ Length int
+
+ QueueFullName, SetFullName string
+}
+
+func toBaseConfig(managedName string, queueSetting setting.QueueSettings) *BaseConfig {
+ baseConfig := &BaseConfig{
+ ManagedName: managedName,
+ DataFullDir: queueSetting.Datadir,
+
+ ConnStr: queueSetting.ConnStr,
+ Length: queueSetting.Length,
+ }
+
+ // queue name and set name
+ baseConfig.QueueFullName = managedName + queueSetting.QueueName
+ baseConfig.SetFullName = baseConfig.QueueFullName + queueSetting.SetName
+ if baseConfig.SetFullName == baseConfig.QueueFullName {
+ baseConfig.SetFullName += "_unique"
+ }
+ return baseConfig
+}
diff --git a/modules/queue/lqinternal/lqinternal.go b/modules/queue/lqinternal/lqinternal.go
new file mode 100644
index 0000000..89aa4e5
--- /dev/null
+++ b/modules/queue/lqinternal/lqinternal.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package lqinternal
+
+import (
+ "bytes"
+ "encoding/binary"
+
+ "github.com/syndtr/goleveldb/leveldb"
+ "github.com/syndtr/goleveldb/leveldb/opt"
+)
+
+func QueueItemIDBytes(id int64) []byte {
+ buf := make([]byte, 8)
+ binary.PutVarint(buf, id)
+ return buf
+}
+
+func QueueItemKeyBytes(prefix []byte, id int64) []byte {
+ key := make([]byte, len(prefix), len(prefix)+1+8)
+ copy(key, prefix)
+ key = append(key, '-')
+ return append(key, QueueItemIDBytes(id)...)
+}
+
+func RemoveLevelQueueKeys(db *leveldb.DB, namePrefix []byte) {
+ keyPrefix := make([]byte, len(namePrefix)+1)
+ copy(keyPrefix, namePrefix)
+ keyPrefix[len(namePrefix)] = '-'
+
+ it := db.NewIterator(nil, &opt.ReadOptions{Strict: opt.NoStrict})
+ defer it.Release()
+ for it.Next() {
+ if bytes.HasPrefix(it.Key(), keyPrefix) {
+ _ = db.Delete(it.Key(), nil)
+ }
+ }
+}
+
+func ListLevelQueueKeys(db *leveldb.DB) (res [][]byte) {
+ it := db.NewIterator(nil, &opt.ReadOptions{Strict: opt.NoStrict})
+ defer it.Release()
+ for it.Next() {
+ res = append(res, it.Key())
+ }
+ return res
+}
diff --git a/modules/queue/manager.go b/modules/queue/manager.go
new file mode 100644
index 0000000..8b964c0
--- /dev/null
+++ b/modules/queue/manager.go
@@ -0,0 +1,113 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Manager is a manager for the queues created by "CreateXxxQueue" functions, these queues are called "managed queues".
+type Manager struct {
+ mu sync.Mutex
+
+ qidCounter int64
+ Queues map[int64]ManagedWorkerPoolQueue
+}
+
+type ManagedWorkerPoolQueue interface {
+ GetName() string
+ GetType() string
+ GetItemTypeName() string
+ GetWorkerNumber() int
+ GetWorkerActiveNumber() int
+ GetWorkerMaxNumber() int
+ SetWorkerMaxNumber(num int)
+ GetQueueItemNumber() int
+
+ // FlushWithContext tries to make the handler process all items in the queue synchronously.
+ // It is for testing purpose only. It's not designed to be used in a cluster.
+ FlushWithContext(ctx context.Context, timeout time.Duration) error
+
+ // RemoveAllItems removes all items in the base queue (on-the-fly items are not affected)
+ RemoveAllItems(ctx context.Context) error
+}
+
+var manager *Manager
+
+func init() {
+ manager = &Manager{
+ Queues: make(map[int64]ManagedWorkerPoolQueue),
+ }
+}
+
+func GetManager() *Manager {
+ return manager
+}
+
+func (m *Manager) AddManagedQueue(managed ManagedWorkerPoolQueue) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.qidCounter++
+ m.Queues[m.qidCounter] = managed
+}
+
+func (m *Manager) GetManagedQueue(qid int64) ManagedWorkerPoolQueue {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.Queues[qid]
+}
+
+func (m *Manager) ManagedQueues() map[int64]ManagedWorkerPoolQueue {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ queues := make(map[int64]ManagedWorkerPoolQueue, len(m.Queues))
+ for k, v := range m.Queues {
+ queues[k] = v
+ }
+ return queues
+}
+
+// FlushAll tries to make all managed queues process all items synchronously, until timeout or the queue is empty.
+// It is for testing purpose only. It's not designed to be used in a cluster.
+func (m *Manager) FlushAll(ctx context.Context, timeout time.Duration) error {
+ var finalErr error
+ qs := m.ManagedQueues()
+ for _, q := range qs {
+ if err := q.FlushWithContext(ctx, timeout); err != nil {
+ finalErr = err // TODO: in Go 1.20: errors.Join
+ }
+ }
+ return finalErr
+}
+
+// CreateSimpleQueue creates a simple queue from global setting config provider by name
+func CreateSimpleQueue[T any](ctx context.Context, name string, handler HandlerFuncT[T]) *WorkerPoolQueue[T] {
+ return createWorkerPoolQueue(ctx, name, setting.CfgProvider, handler, false)
+}
+
+// CreateUniqueQueue creates a unique queue from global setting config provider by name
+func CreateUniqueQueue[T any](ctx context.Context, name string, handler HandlerFuncT[T]) *WorkerPoolQueue[T] {
+ return createWorkerPoolQueue(ctx, name, setting.CfgProvider, handler, true)
+}
+
+func createWorkerPoolQueue[T any](ctx context.Context, name string, cfgProvider setting.ConfigProvider, handler HandlerFuncT[T], unique bool) *WorkerPoolQueue[T] {
+ queueSetting, err := setting.GetQueueSettings(cfgProvider, name)
+ if err != nil {
+ log.Error("Failed to get queue settings for %q: %v", name, err)
+ return nil
+ }
+ w, err := NewWorkerPoolQueueWithContext(ctx, name, queueSetting, handler, unique)
+ if err != nil {
+ log.Error("Failed to create queue %q: %v", name, err)
+ return nil
+ }
+ GetManager().AddManagedQueue(w)
+ return w
+}
diff --git a/modules/queue/manager_test.go b/modules/queue/manager_test.go
new file mode 100644
index 0000000..a76c238
--- /dev/null
+++ b/modules/queue/manager_test.go
@@ -0,0 +1,125 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestManager(t *testing.T) {
+ oldAppDataPath := setting.AppDataPath
+ setting.AppDataPath = t.TempDir()
+ defer func() {
+ setting.AppDataPath = oldAppDataPath
+ }()
+
+ newQueueFromConfig := func(name, cfg string) (*WorkerPoolQueue[int], error) {
+ cfgProvider, err := setting.NewConfigProviderFromData(cfg)
+ if err != nil {
+ return nil, err
+ }
+ qs, err := setting.GetQueueSettings(cfgProvider, name)
+ if err != nil {
+ return nil, err
+ }
+ return newWorkerPoolQueueForTest(name, qs, func(s ...int) (unhandled []int) { return nil }, false)
+ }
+
+ // test invalid CONN_STR
+ _, err := newQueueFromConfig("default", `
+[queue]
+DATADIR = temp-dir
+CONN_STR = redis://
+`)
+ require.ErrorContains(t, err, "invalid leveldb connection string")
+
+ // test default config
+ q, err := newQueueFromConfig("default", "")
+ require.NoError(t, err)
+ assert.Equal(t, "default", q.GetName())
+ assert.Equal(t, "level", q.GetType())
+ assert.Equal(t, filepath.Join(setting.AppDataPath, "queues/common"), q.baseConfig.DataFullDir)
+ assert.Equal(t, 100000, q.baseConfig.Length)
+ assert.Equal(t, 20, q.batchLength)
+ assert.Equal(t, "", q.baseConfig.ConnStr)
+ assert.Equal(t, "default_queue", q.baseConfig.QueueFullName)
+ assert.Equal(t, "default_queue_unique", q.baseConfig.SetFullName)
+ assert.NotZero(t, q.GetWorkerMaxNumber())
+ assert.Equal(t, 0, q.GetWorkerNumber())
+ assert.Equal(t, 0, q.GetWorkerActiveNumber())
+ assert.Equal(t, 0, q.GetQueueItemNumber())
+ assert.Equal(t, "int", q.GetItemTypeName())
+
+ // test inherited config
+ cfgProvider, err := setting.NewConfigProviderFromData(`
+[queue]
+TYPE = channel
+DATADIR = queues/dir1
+LENGTH = 100
+BATCH_LENGTH = 20
+CONN_STR = "addrs=127.0.0.1:6379 db=0"
+QUEUE_NAME = _queue1
+
+[queue.sub]
+TYPE = level
+DATADIR = queues/dir2
+LENGTH = 102
+BATCH_LENGTH = 22
+CONN_STR =
+QUEUE_NAME = _q2
+SET_NAME = _u2
+MAX_WORKERS = 123
+`)
+
+ require.NoError(t, err)
+
+ q1 := createWorkerPoolQueue[string](context.Background(), "no-such", cfgProvider, nil, false)
+ assert.Equal(t, "no-such", q1.GetName())
+ assert.Equal(t, "dummy", q1.GetType()) // no handler, so it becomes dummy
+ assert.Equal(t, filepath.Join(setting.AppDataPath, "queues/dir1"), q1.baseConfig.DataFullDir)
+ assert.Equal(t, 100, q1.baseConfig.Length)
+ assert.Equal(t, 20, q1.batchLength)
+ assert.Equal(t, "addrs=127.0.0.1:6379 db=0", q1.baseConfig.ConnStr)
+ assert.Equal(t, "no-such_queue1", q1.baseConfig.QueueFullName)
+ assert.Equal(t, "no-such_queue1_unique", q1.baseConfig.SetFullName)
+ assert.NotZero(t, q1.GetWorkerMaxNumber())
+ assert.Equal(t, 0, q1.GetWorkerNumber())
+ assert.Equal(t, 0, q1.GetWorkerActiveNumber())
+ assert.Equal(t, 0, q1.GetQueueItemNumber())
+ assert.Equal(t, "string", q1.GetItemTypeName())
+ qid1 := GetManager().qidCounter
+
+ q2 := createWorkerPoolQueue(context.Background(), "sub", cfgProvider, func(s ...int) (unhandled []int) { return nil }, false)
+ assert.Equal(t, "sub", q2.GetName())
+ assert.Equal(t, "level", q2.GetType())
+ assert.Equal(t, filepath.Join(setting.AppDataPath, "queues/dir2"), q2.baseConfig.DataFullDir)
+ assert.Equal(t, 102, q2.baseConfig.Length)
+ assert.Equal(t, 22, q2.batchLength)
+ assert.Equal(t, "", q2.baseConfig.ConnStr)
+ assert.Equal(t, "sub_q2", q2.baseConfig.QueueFullName)
+ assert.Equal(t, "sub_q2_u2", q2.baseConfig.SetFullName)
+ assert.Equal(t, 123, q2.GetWorkerMaxNumber())
+ assert.Equal(t, 0, q2.GetWorkerNumber())
+ assert.Equal(t, 0, q2.GetWorkerActiveNumber())
+ assert.Equal(t, 0, q2.GetQueueItemNumber())
+ assert.Equal(t, "int", q2.GetItemTypeName())
+ qid2 := GetManager().qidCounter
+
+ assert.Equal(t, q1, GetManager().ManagedQueues()[qid1])
+
+ GetManager().GetManagedQueue(qid1).SetWorkerMaxNumber(120)
+ assert.Equal(t, 120, q1.workerMaxNum)
+
+ stop := runWorkerPoolQueue(q2)
+ require.NoError(t, GetManager().GetManagedQueue(qid2).FlushWithContext(context.Background(), 0))
+ require.NoError(t, GetManager().FlushAll(context.Background(), 0))
+ stop()
+}
diff --git a/modules/queue/mock/inmemorymockredis.go b/modules/queue/mock/inmemorymockredis.go
new file mode 100644
index 0000000..de8bd8a
--- /dev/null
+++ b/modules/queue/mock/inmemorymockredis.go
@@ -0,0 +1,133 @@
+package mock
+
+import (
+ "context"
+ "errors"
+
+ redis "github.com/redis/go-redis/v9"
+)
+
+// InMemoryMockRedis is a very primitive in-memory redis-like feature. The main
+// purpose of this struct is to give some backend to mocked unit tests.
+type InMemoryMockRedis struct {
+ queues map[string][][]byte
+}
+
+func NewInMemoryMockRedis() InMemoryMockRedis {
+ return InMemoryMockRedis{
+ queues: map[string][][]byte{},
+ }
+}
+
+func (r *InMemoryMockRedis) LLen(ctx context.Context, key string) *redis.IntCmd {
+ cmd := redis.NewIntCmd(ctx)
+ cmd.SetVal(int64(len(r.queues[key])))
+ return cmd
+}
+
+func (r *InMemoryMockRedis) SAdd(ctx context.Context, key string, content []byte) *redis.IntCmd {
+ cmd := redis.NewIntCmd(ctx)
+
+ for _, value := range r.queues[key] {
+ if string(value) == string(content) {
+ cmd.SetVal(0)
+
+ return cmd
+ }
+ }
+
+ r.queues[key] = append(r.queues[key], content)
+
+ cmd.SetVal(1)
+
+ return cmd
+}
+
+func (r *InMemoryMockRedis) RPush(ctx context.Context, key string, content []byte) *redis.IntCmd {
+ cmd := redis.NewIntCmd(ctx)
+
+ r.queues[key] = append(r.queues[key], content)
+
+ cmd.SetVal(1)
+
+ return cmd
+}
+
+func (r *InMemoryMockRedis) LPop(ctx context.Context, key string) *redis.StringCmd {
+ cmd := redis.NewStringCmd(ctx)
+
+ queue, found := r.queues[key]
+ if !found {
+ cmd.SetErr(errors.New("queue not found"))
+
+ return cmd
+ }
+
+ if len(queue) < 1 {
+ cmd.SetErr(errors.New("queue is empty"))
+
+ return cmd
+ }
+
+ value, rest := queue[0], queue[1:]
+
+ r.queues[key] = rest
+
+ cmd.SetVal(string(value))
+
+ return cmd
+}
+
+func (r *InMemoryMockRedis) SRem(ctx context.Context, key string, content []byte) *redis.IntCmd {
+ cmd := redis.NewIntCmd(ctx)
+
+ queue, found := r.queues[key]
+ if !found {
+ cmd.SetErr(errors.New("queue not found"))
+
+ return cmd
+ }
+
+ if len(queue) < 1 {
+ cmd.SetErr(errors.New("queue is empty"))
+
+ return cmd
+ }
+
+ newList := [][]byte{}
+
+ for _, value := range queue {
+ if string(value) != string(content) {
+ newList = append(newList, value)
+ }
+ }
+
+ r.queues[key] = newList
+
+ cmd.SetVal(1)
+
+ return cmd
+}
+
+func (r *InMemoryMockRedis) SIsMember(ctx context.Context, key string, content []byte) *redis.BoolCmd {
+ cmd := redis.NewBoolCmd(ctx)
+
+ queue, found := r.queues[key]
+ if !found {
+ cmd.SetErr(errors.New("queue not found"))
+
+ return cmd
+ }
+
+ for _, value := range queue {
+ if string(value) == string(content) {
+ cmd.SetVal(true)
+
+ return cmd
+ }
+ }
+
+ cmd.SetVal(false)
+
+ return cmd
+}
diff --git a/modules/queue/mock/redisuniversalclient.go b/modules/queue/mock/redisuniversalclient.go
new file mode 100644
index 0000000..36e4b7c
--- /dev/null
+++ b/modules/queue/mock/redisuniversalclient.go
@@ -0,0 +1,343 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: code.gitea.io/gitea/modules/nosql (interfaces: RedisClient)
+//
+// Generated by this command:
+//
+// mockgen -package mock -destination ./modules/queue/mock/redisuniversalclient.go code.gitea.io/gitea/modules/nosql RedisClient
+//
+
+// Package mock is a generated GoMock package.
+package mock
+
+import (
+ context "context"
+ reflect "reflect"
+ time "time"
+
+ redis "github.com/redis/go-redis/v9"
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockRedisClient is a mock of RedisClient interface.
+type MockRedisClient struct {
+ ctrl *gomock.Controller
+ recorder *MockRedisClientMockRecorder
+}
+
+// MockRedisClientMockRecorder is the mock recorder for MockRedisClient.
+type MockRedisClientMockRecorder struct {
+ mock *MockRedisClient
+}
+
+// NewMockRedisClient creates a new mock instance.
+func NewMockRedisClient(ctrl *gomock.Controller) *MockRedisClient {
+ mock := &MockRedisClient{ctrl: ctrl}
+ mock.recorder = &MockRedisClientMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockRedisClient) EXPECT() *MockRedisClientMockRecorder {
+ return m.recorder
+}
+
+// Close mocks base method.
+func (m *MockRedisClient) Close() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Close")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockRedisClientMockRecorder) Close() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockRedisClient)(nil).Close))
+}
+
+// DBSize mocks base method.
+func (m *MockRedisClient) DBSize(arg0 context.Context) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DBSize", arg0)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// DBSize indicates an expected call of DBSize.
+func (mr *MockRedisClientMockRecorder) DBSize(arg0 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBSize", reflect.TypeOf((*MockRedisClient)(nil).DBSize), arg0)
+}
+
+// Decr mocks base method.
+func (m *MockRedisClient) Decr(arg0 context.Context, arg1 string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Decr", arg0, arg1)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// Decr indicates an expected call of Decr.
+func (mr *MockRedisClientMockRecorder) Decr(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decr", reflect.TypeOf((*MockRedisClient)(nil).Decr), arg0, arg1)
+}
+
+// Del mocks base method.
+func (m *MockRedisClient) Del(arg0 context.Context, arg1 ...string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Del", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// Del indicates an expected call of Del.
+func (mr *MockRedisClientMockRecorder) Del(arg0 any, arg1 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Del", reflect.TypeOf((*MockRedisClient)(nil).Del), varargs...)
+}
+
+// Exists mocks base method.
+func (m *MockRedisClient) Exists(arg0 context.Context, arg1 ...string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Exists", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// Exists indicates an expected call of Exists.
+func (mr *MockRedisClientMockRecorder) Exists(arg0 any, arg1 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exists", reflect.TypeOf((*MockRedisClient)(nil).Exists), varargs...)
+}
+
+// FlushDB mocks base method.
+func (m *MockRedisClient) FlushDB(arg0 context.Context) *redis.StatusCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FlushDB", arg0)
+ ret0, _ := ret[0].(*redis.StatusCmd)
+ return ret0
+}
+
+// FlushDB indicates an expected call of FlushDB.
+func (mr *MockRedisClientMockRecorder) FlushDB(arg0 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FlushDB", reflect.TypeOf((*MockRedisClient)(nil).FlushDB), arg0)
+}
+
+// Get mocks base method.
+func (m *MockRedisClient) Get(arg0 context.Context, arg1 string) *redis.StringCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", arg0, arg1)
+ ret0, _ := ret[0].(*redis.StringCmd)
+ return ret0
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockRedisClientMockRecorder) Get(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRedisClient)(nil).Get), arg0, arg1)
+}
+
+// HDel mocks base method.
+func (m *MockRedisClient) HDel(arg0 context.Context, arg1 string, arg2 ...string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "HDel", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// HDel indicates an expected call of HDel.
+func (mr *MockRedisClientMockRecorder) HDel(arg0, arg1 any, arg2 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HDel", reflect.TypeOf((*MockRedisClient)(nil).HDel), varargs...)
+}
+
+// HKeys mocks base method.
+func (m *MockRedisClient) HKeys(arg0 context.Context, arg1 string) *redis.StringSliceCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "HKeys", arg0, arg1)
+ ret0, _ := ret[0].(*redis.StringSliceCmd)
+ return ret0
+}
+
+// HKeys indicates an expected call of HKeys.
+func (mr *MockRedisClientMockRecorder) HKeys(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HKeys", reflect.TypeOf((*MockRedisClient)(nil).HKeys), arg0, arg1)
+}
+
+// HSet mocks base method.
+func (m *MockRedisClient) HSet(arg0 context.Context, arg1 string, arg2 ...any) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "HSet", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// HSet indicates an expected call of HSet.
+func (mr *MockRedisClientMockRecorder) HSet(arg0, arg1 any, arg2 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HSet", reflect.TypeOf((*MockRedisClient)(nil).HSet), varargs...)
+}
+
+// Incr mocks base method.
+func (m *MockRedisClient) Incr(arg0 context.Context, arg1 string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Incr", arg0, arg1)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// Incr indicates an expected call of Incr.
+func (mr *MockRedisClientMockRecorder) Incr(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Incr", reflect.TypeOf((*MockRedisClient)(nil).Incr), arg0, arg1)
+}
+
+// LLen mocks base method.
+func (m *MockRedisClient) LLen(arg0 context.Context, arg1 string) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "LLen", arg0, arg1)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// LLen indicates an expected call of LLen.
+func (mr *MockRedisClientMockRecorder) LLen(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LLen", reflect.TypeOf((*MockRedisClient)(nil).LLen), arg0, arg1)
+}
+
+// LPop mocks base method.
+func (m *MockRedisClient) LPop(arg0 context.Context, arg1 string) *redis.StringCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "LPop", arg0, arg1)
+ ret0, _ := ret[0].(*redis.StringCmd)
+ return ret0
+}
+
+// LPop indicates an expected call of LPop.
+func (mr *MockRedisClientMockRecorder) LPop(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LPop", reflect.TypeOf((*MockRedisClient)(nil).LPop), arg0, arg1)
+}
+
+// Ping mocks base method.
+func (m *MockRedisClient) Ping(arg0 context.Context) *redis.StatusCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Ping", arg0)
+ ret0, _ := ret[0].(*redis.StatusCmd)
+ return ret0
+}
+
+// Ping indicates an expected call of Ping.
+func (mr *MockRedisClientMockRecorder) Ping(arg0 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockRedisClient)(nil).Ping), arg0)
+}
+
+// RPush mocks base method.
+func (m *MockRedisClient) RPush(arg0 context.Context, arg1 string, arg2 ...any) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "RPush", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// RPush indicates an expected call of RPush.
+func (mr *MockRedisClientMockRecorder) RPush(arg0, arg1 any, arg2 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RPush", reflect.TypeOf((*MockRedisClient)(nil).RPush), varargs...)
+}
+
+// SAdd mocks base method.
+func (m *MockRedisClient) SAdd(arg0 context.Context, arg1 string, arg2 ...any) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "SAdd", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// SAdd indicates an expected call of SAdd.
+func (mr *MockRedisClientMockRecorder) SAdd(arg0, arg1 any, arg2 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SAdd", reflect.TypeOf((*MockRedisClient)(nil).SAdd), varargs...)
+}
+
+// SIsMember mocks base method.
+func (m *MockRedisClient) SIsMember(arg0 context.Context, arg1 string, arg2 any) *redis.BoolCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SIsMember", arg0, arg1, arg2)
+ ret0, _ := ret[0].(*redis.BoolCmd)
+ return ret0
+}
+
+// SIsMember indicates an expected call of SIsMember.
+func (mr *MockRedisClientMockRecorder) SIsMember(arg0, arg1, arg2 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SIsMember", reflect.TypeOf((*MockRedisClient)(nil).SIsMember), arg0, arg1, arg2)
+}
+
+// SRem mocks base method.
+func (m *MockRedisClient) SRem(arg0 context.Context, arg1 string, arg2 ...any) *redis.IntCmd {
+ m.ctrl.T.Helper()
+ varargs := []any{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "SRem", varargs...)
+ ret0, _ := ret[0].(*redis.IntCmd)
+ return ret0
+}
+
+// SRem indicates an expected call of SRem.
+func (mr *MockRedisClientMockRecorder) SRem(arg0, arg1 any, arg2 ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SRem", reflect.TypeOf((*MockRedisClient)(nil).SRem), varargs...)
+}
+
+// Set mocks base method.
+func (m *MockRedisClient) Set(arg0 context.Context, arg1 string, arg2 any, arg3 time.Duration) *redis.StatusCmd {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Set", arg0, arg1, arg2, arg3)
+ ret0, _ := ret[0].(*redis.StatusCmd)
+ return ret0
+}
+
+// Set indicates an expected call of Set.
+func (mr *MockRedisClientMockRecorder) Set(arg0, arg1, arg2, arg3 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockRedisClient)(nil).Set), arg0, arg1, arg2, arg3)
+}
diff --git a/modules/queue/queue.go b/modules/queue/queue.go
new file mode 100644
index 0000000..5683501
--- /dev/null
+++ b/modules/queue/queue.go
@@ -0,0 +1,68 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package queue implements a specialized concurrent queue system for Gitea.
+//
+// Terminology:
+//
+// 1. Item:
+// - An item can be a simple value, such as an integer, or a more complex structure that has multiple fields.
+// Usually a item serves as a task or a message. Sets of items will be sent to a queue handler to be processed.
+// - It's represented as a JSON-marshaled binary slice in the queue
+// - Since the item is marshaled by JSON, and JSON doesn't have stable key-order/type support,
+// so the decoded handler item may not be the same as the original "pushed" one if you use map/any types,
+//
+// 2. Batch:
+// - A collection of items that are grouped together for processing. Each worker receives a batch of items.
+//
+// 3. Worker:
+// - Individual unit of execution designed to process items from the queue. It's a goroutine that calls the Handler.
+// - Workers will get new items through a channel (WorkerPoolQueue is responsible for the distribution).
+// - Workers operate in parallel. The default value of max workers is determined by the setting system.
+//
+// 4. Handler (represented by HandlerFuncT type):
+// - It's the function responsible for processing items. Each active worker will call it.
+// - If an item or some items are not successfully processed, the handler could return them as "unhandled items".
+// In such scenarios, the queue system ensures these unhandled items are returned to the base queue after a brief delay.
+// This mechanism is particularly beneficial in cases where the processing entity (like a document indexer) is
+// temporarily unavailable. It ensures that no item is skipped or lost due to transient failures in the processing
+// mechanism.
+//
+// 5. Base queue:
+// - Represents the underlying storage mechanism for the queue. There are several implementations:
+// - Channel: Uses Go's native channel constructs to manage the queue, suitable for in-memory queuing.
+// - LevelDB: Especially useful in persistent queues for single instances.
+// - Redis: Suitable for clusters, where we may have multiple nodes.
+// - Dummy: This is special, it's not a real queue, it's a immediate no-op queue, which is useful for tests.
+// - They all have the same abstraction, the same interface, and they are tested by the same testing code.
+//
+// 6. WorkerPoolQueue:
+// - It's responsible to glue all together, using the "base queue" to provide "worker pool" functionality. It creates
+// new workers if needed and can flush the queue, running all the items synchronously till it finishes.
+// - Its "Push" function doesn't block forever, it will return an error if the queue is full after the timeout.
+//
+// 7. Manager:
+// - The purpose of it is to serve as a centralized manager for multiple WorkerPoolQueue instances. Whenever we want
+// to create a new queue, flush, or get a specific queue, we could use it.
+//
+// A queue can be "simple" or "unique". A unique queue will try to avoid duplicate items.
+// Unique queue's "Has" function can be used to check whether an item is already in the queue,
+// although it's not 100% reliable due to the lack of proper transaction support.
+// Simple queue's "Has" function always returns "has=false".
+//
+// A WorkerPoolQueue is a generic struct; this means it will work with any type but just for that type.
+// If you want another kind of items to run, you would have to call the manager to create a new WorkerPoolQueue for you
+// with a different handler that works with this new type of item. As an example of this:
+//
+// func Init() error {
+// itemQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "queue-name", handler)
+// ...
+// }
+// func handler(items ...*mypkg.QueueItem) []*mypkg.QueueItem { ... }
+package queue
+
+import "code.gitea.io/gitea/modules/util"
+
+type HandlerFuncT[T any] func(...T) (unhandled []T)
+
+var ErrAlreadyInQueue = util.NewAlreadyExistErrorf("already in queue")
diff --git a/modules/queue/testhelper.go b/modules/queue/testhelper.go
new file mode 100644
index 0000000..edfa438
--- /dev/null
+++ b/modules/queue/testhelper.go
@@ -0,0 +1,40 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "fmt"
+ "sync"
+)
+
+// testStateRecorder is used to record state changes for testing, to help debug async behaviors
+type testStateRecorder struct {
+ records []string
+ mu sync.Mutex
+}
+
+var testRecorder = &testStateRecorder{}
+
+func (t *testStateRecorder) Record(format string, args ...any) {
+ t.mu.Lock()
+ t.records = append(t.records, fmt.Sprintf(format, args...))
+ if len(t.records) > 1000 {
+ t.records = t.records[len(t.records)-1000:]
+ }
+ t.mu.Unlock()
+}
+
+func (t *testStateRecorder) Records() []string {
+ t.mu.Lock()
+ r := make([]string, len(t.records))
+ copy(r, t.records)
+ t.mu.Unlock()
+ return r
+}
+
+func (t *testStateRecorder) Reset() {
+ t.mu.Lock()
+ t.records = nil
+ t.mu.Unlock()
+}
diff --git a/modules/queue/workergroup.go b/modules/queue/workergroup.go
new file mode 100644
index 0000000..ea4c002
--- /dev/null
+++ b/modules/queue/workergroup.go
@@ -0,0 +1,350 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "runtime/pprof"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var (
+ infiniteTimerC = make(chan time.Time)
+ batchDebounceDuration = 100 * time.Millisecond
+ workerIdleDuration = 1 * time.Second
+ shutdownDefaultTimeout = 2 * time.Second
+
+ unhandledItemRequeueDuration atomic.Int64 // to avoid data race during test
+)
+
+func init() {
+ unhandledItemRequeueDuration.Store(int64(5 * time.Second))
+}
+
+// workerGroup is a group of workers to work with a WorkerPoolQueue
+type workerGroup[T any] struct {
+ q *WorkerPoolQueue[T]
+ wg sync.WaitGroup
+
+ ctxWorker context.Context
+ ctxWorkerCancel context.CancelFunc
+
+ batchBuffer []T
+ popItemChan chan []byte
+ popItemErr chan error
+}
+
+func (wg *workerGroup[T]) doPrepareWorkerContext() {
+ wg.ctxWorker, wg.ctxWorkerCancel = context.WithCancel(wg.q.ctxRun)
+}
+
+// doDispatchBatchToWorker dispatches a batch of items to worker's channel.
+// If the channel is full, it tries to start a new worker if possible.
+func (q *WorkerPoolQueue[T]) doDispatchBatchToWorker(wg *workerGroup[T], flushChan chan flushType) {
+ batch := wg.batchBuffer
+ wg.batchBuffer = nil
+
+ if len(batch) == 0 {
+ return
+ }
+
+ full := false
+ select {
+ case q.batchChan <- batch:
+ default:
+ full = true
+ }
+
+ // TODO: the logic could be improved in the future, to avoid a data-race between "doStartNewWorker" and "workerNum"
+ // The root problem is that if we skip "doStartNewWorker" here, the "workerNum" might be decreased by other workers later
+ // So ideally, it should check whether there are enough workers by some approaches, and start new workers if necessary.
+ q.workerNumMu.Lock()
+ noWorker := q.workerNum == 0
+ if full || noWorker {
+ if q.workerNum < q.workerMaxNum || noWorker && q.workerMaxNum <= 0 {
+ q.workerNum++
+ q.doStartNewWorker(wg)
+ }
+ }
+ q.workerNumMu.Unlock()
+
+ if full {
+ select {
+ case q.batchChan <- batch:
+ case flush := <-flushChan:
+ q.doWorkerHandle(batch)
+ q.doFlush(wg, flush)
+ case <-q.ctxRun.Done():
+ wg.batchBuffer = batch // return the batch to buffer, the "doRun" function will handle it
+ }
+ }
+}
+
+// doWorkerHandle calls the safeHandler to handle a batch of items, and it increases/decreases the active worker number.
+// If the context has been canceled, it should not be caller because the "Push" still needs the context, in such case, call q.safeHandler directly
+func (q *WorkerPoolQueue[T]) doWorkerHandle(batch []T) {
+ q.workerNumMu.Lock()
+ q.workerActiveNum++
+ q.workerNumMu.Unlock()
+
+ defer func() {
+ q.workerNumMu.Lock()
+ q.workerActiveNum--
+ q.workerNumMu.Unlock()
+ }()
+
+ unhandled := q.safeHandler(batch...)
+ // if none of the items were handled, it should back-off for a few seconds
+ // in this case the handler (eg: document indexer) may have encountered some errors/failures
+ if len(unhandled) == len(batch) && unhandledItemRequeueDuration.Load() != 0 {
+ log.Error("Queue %q failed to handle batch of %d items, backoff for a few seconds", q.GetName(), len(batch))
+ select {
+ case <-q.ctxRun.Done():
+ case <-time.After(time.Duration(unhandledItemRequeueDuration.Load())):
+ }
+ }
+ for _, item := range unhandled {
+ if err := q.Push(item); err != nil {
+ if !q.basePushForShutdown(item) {
+ log.Error("Failed to requeue item for queue %q when calling handler: %v", q.GetName(), err)
+ }
+ }
+ }
+}
+
+// basePushForShutdown tries to requeue items into the base queue when the WorkerPoolQueue is shutting down.
+// If the queue is shutting down, it returns true and try to push the items
+// Otherwise it does nothing and returns false
+func (q *WorkerPoolQueue[T]) basePushForShutdown(items ...T) bool {
+ shutdownTimeout := time.Duration(q.shutdownTimeout.Load())
+ if shutdownTimeout == 0 {
+ return false
+ }
+ ctxShutdown, ctxShutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer ctxShutdownCancel()
+ for _, item := range items {
+ // if there is still any error, the queue can do nothing instead of losing the items
+ if err := q.baseQueue.PushItem(ctxShutdown, q.marshal(item)); err != nil {
+ log.Error("Failed to requeue item for queue %q when shutting down: %v", q.GetName(), err)
+ }
+ }
+ return true
+}
+
+// doStartNewWorker starts a new worker for the queue, the worker reads from worker's channel and handles the items.
+func (q *WorkerPoolQueue[T]) doStartNewWorker(wp *workerGroup[T]) {
+ wp.wg.Add(1)
+
+ go func() {
+ defer wp.wg.Done()
+
+ log.Debug("Queue %q starts new worker", q.GetName())
+ defer log.Debug("Queue %q stops idle worker", q.GetName())
+
+ t := time.NewTicker(workerIdleDuration)
+ defer t.Stop()
+
+ keepWorking := true
+ stopWorking := func() {
+ q.workerNumMu.Lock()
+ keepWorking = false
+ q.workerNum--
+ q.workerNumMu.Unlock()
+ }
+ for keepWorking {
+ select {
+ case <-wp.ctxWorker.Done():
+ stopWorking()
+ case batch, ok := <-q.batchChan:
+ if !ok {
+ stopWorking()
+ continue
+ }
+ q.doWorkerHandle(batch)
+ // reset the idle ticker, and drain the tick after reset in case a tick is already triggered
+ t.Reset(workerIdleDuration)
+ select {
+ case <-t.C:
+ default:
+ }
+ case <-t.C:
+ q.workerNumMu.Lock()
+ keepWorking = q.workerNum <= 1 // keep the last worker running
+ if !keepWorking {
+ q.workerNum--
+ }
+ q.workerNumMu.Unlock()
+ }
+ }
+ }()
+}
+
+// doFlush flushes the queue: it tries to read all items from the queue and handles them.
+// It is for testing purpose only. It's not designed to work for a cluster.
+func (q *WorkerPoolQueue[T]) doFlush(wg *workerGroup[T], flush flushType) {
+ log.Debug("Queue %q starts flushing", q.GetName())
+ defer log.Debug("Queue %q finishes flushing", q.GetName())
+
+ // stop all workers, and prepare a new worker context to start new workers
+
+ wg.ctxWorkerCancel()
+ wg.wg.Wait()
+
+ defer func() {
+ close(flush)
+ wg.doPrepareWorkerContext()
+ }()
+
+ // drain the batch channel first
+loop:
+ for {
+ select {
+ case batch := <-q.batchChan:
+ q.doWorkerHandle(batch)
+ default:
+ break loop
+ }
+ }
+
+ // drain the popItem channel
+ emptyCounter := 0
+ for {
+ select {
+ case data, dataOk := <-wg.popItemChan:
+ if !dataOk {
+ return
+ }
+ emptyCounter = 0
+ if v, jsonOk := q.unmarshal(data); !jsonOk {
+ continue
+ } else {
+ q.doWorkerHandle([]T{v})
+ }
+ case err := <-wg.popItemErr:
+ if !q.isCtxRunCanceled() {
+ log.Error("Failed to pop item from queue %q (doFlush): %v", q.GetName(), err)
+ }
+ return
+ case <-q.ctxRun.Done():
+ log.Debug("Queue %q is shutting down", q.GetName())
+ return
+ case <-time.After(20 * time.Millisecond):
+ // There is no reliable way to make sure all queue items are consumed by the Flush, there always might be some items stored in some buffers/temp variables.
+ // If we run Gitea in a cluster, we can even not guarantee all items are consumed in a deterministic instance.
+ // Luckily, the "Flush" trick is only used in tests, so far so good.
+ if cnt, _ := q.baseQueue.Len(q.ctxRun); cnt == 0 && len(wg.popItemChan) == 0 {
+ emptyCounter++
+ }
+ if emptyCounter >= 2 {
+ return
+ }
+ }
+ }
+}
+
+func (q *WorkerPoolQueue[T]) isCtxRunCanceled() bool {
+ select {
+ case <-q.ctxRun.Done():
+ return true
+ default:
+ return false
+ }
+}
+
+var skipFlushChan = make(chan flushType) // an empty flush chan, used to skip reading other flush requests
+
+// doRun is the main loop of the queue. All related "doXxx" functions are executed in its context.
+func (q *WorkerPoolQueue[T]) doRun() {
+ pprof.SetGoroutineLabels(q.ctxRun)
+
+ log.Debug("Queue %q starts running", q.GetName())
+ defer log.Debug("Queue %q stops running", q.GetName())
+
+ wg := &workerGroup[T]{q: q}
+ wg.doPrepareWorkerContext()
+ wg.popItemChan, wg.popItemErr = popItemByChan(q.ctxRun, q.baseQueue.PopItem)
+
+ defer func() {
+ q.ctxRunCancel()
+
+ // drain all data on the fly
+ // since the queue is shutting down, the items can't be dispatched to workers because the context is canceled
+ // it can't call doWorkerHandle either, because there is no chance to push unhandled items back to the queue
+ var unhandled []T
+ close(q.batchChan)
+ for batch := range q.batchChan {
+ unhandled = append(unhandled, batch...)
+ }
+ unhandled = append(unhandled, wg.batchBuffer...)
+ for data := range wg.popItemChan {
+ if v, ok := q.unmarshal(data); ok {
+ unhandled = append(unhandled, v)
+ }
+ }
+
+ shutdownTimeout := time.Duration(q.shutdownTimeout.Load())
+ if shutdownTimeout != 0 {
+ // if there is a shutdown context, try to push the items back to the base queue
+ q.basePushForShutdown(unhandled...)
+ workerDone := make(chan struct{})
+ // the only way to wait for the workers, because the handlers do not have context to wait for
+ go func() { wg.wg.Wait(); close(workerDone) }()
+ select {
+ case <-workerDone:
+ case <-time.After(shutdownTimeout):
+ log.Error("Queue %q is shutting down, but workers are still running after timeout", q.GetName())
+ }
+ } else {
+ // if there is no shutdown context, just call the handler to try to handle the items. if the handler fails again, the items are lost
+ q.safeHandler(unhandled...)
+ }
+
+ close(q.shutdownDone)
+ }()
+
+ var batchDispatchC <-chan time.Time = infiniteTimerC
+ for {
+ select {
+ case data, dataOk := <-wg.popItemChan:
+ if !dataOk {
+ return
+ }
+ if v, jsonOk := q.unmarshal(data); !jsonOk {
+ testRecorder.Record("pop:corrupted:%s", data) // in rare cases the levelqueue(leveldb) might be corrupted
+ continue
+ } else {
+ wg.batchBuffer = append(wg.batchBuffer, v)
+ }
+ if len(wg.batchBuffer) >= q.batchLength {
+ q.doDispatchBatchToWorker(wg, q.flushChan)
+ } else if batchDispatchC == infiniteTimerC {
+ batchDispatchC = time.After(batchDebounceDuration)
+ } // else: batchDispatchC is already a debounce timer, it will be triggered soon
+ case <-batchDispatchC:
+ batchDispatchC = infiniteTimerC
+ q.doDispatchBatchToWorker(wg, q.flushChan)
+ case flush := <-q.flushChan:
+ // before flushing, it needs to try to dispatch the batch to worker first, in case there is no worker running
+ // after the flushing, there is at least one worker running, so "doFlush" could wait for workers to finish
+ // since we are already in a "flush" operation, so the dispatching function shouldn't read the flush chan.
+ q.doDispatchBatchToWorker(wg, skipFlushChan)
+ q.doFlush(wg, flush)
+ case err, errOk := <-wg.popItemErr:
+ if !errOk {
+ return
+ }
+ if !q.isCtxRunCanceled() {
+ log.Error("Failed to pop item from queue %q (doRun): %v", q.GetName(), err)
+ }
+ return
+ case <-q.ctxRun.Done():
+ log.Debug("Queue %q is shutting down", q.GetName())
+ return
+ }
+ }
+}
diff --git a/modules/queue/workerqueue.go b/modules/queue/workerqueue.go
new file mode 100644
index 0000000..041ce9a
--- /dev/null
+++ b/modules/queue/workerqueue.go
@@ -0,0 +1,260 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// WorkerPoolQueue is a queue that uses a pool of workers to process items
+// It can use different underlying (base) queue types
+type WorkerPoolQueue[T any] struct {
+ ctxRun context.Context
+ ctxRunCancel context.CancelFunc
+
+ shutdownDone chan struct{}
+ shutdownTimeout atomic.Int64 // in case some buggy handlers (workers) would hang forever, "shutdown" should finish in predictable time
+
+ origHandler HandlerFuncT[T]
+ safeHandler HandlerFuncT[T]
+
+ baseQueueType string
+ baseConfig *BaseConfig
+ baseQueue baseQueue
+
+ batchChan chan []T
+ flushChan chan flushType
+
+ batchLength int
+ workerNum int
+ workerMaxNum int
+ workerActiveNum int
+ workerNumMu sync.Mutex
+}
+
+type flushType chan struct{}
+
+var _ ManagedWorkerPoolQueue = (*WorkerPoolQueue[any])(nil)
+
+func (q *WorkerPoolQueue[T]) GetName() string {
+ return q.baseConfig.ManagedName
+}
+
+func (q *WorkerPoolQueue[T]) GetType() string {
+ return q.baseQueueType
+}
+
+func (q *WorkerPoolQueue[T]) GetItemTypeName() string {
+ var t T
+ return fmt.Sprintf("%T", t)
+}
+
+func (q *WorkerPoolQueue[T]) GetWorkerNumber() int {
+ q.workerNumMu.Lock()
+ defer q.workerNumMu.Unlock()
+ return q.workerNum
+}
+
+func (q *WorkerPoolQueue[T]) GetWorkerActiveNumber() int {
+ q.workerNumMu.Lock()
+ defer q.workerNumMu.Unlock()
+ return q.workerActiveNum
+}
+
+func (q *WorkerPoolQueue[T]) GetWorkerMaxNumber() int {
+ q.workerNumMu.Lock()
+ defer q.workerNumMu.Unlock()
+ return q.workerMaxNum
+}
+
+func (q *WorkerPoolQueue[T]) SetWorkerMaxNumber(num int) {
+ q.workerNumMu.Lock()
+ defer q.workerNumMu.Unlock()
+ q.workerMaxNum = num
+}
+
+func (q *WorkerPoolQueue[T]) GetQueueItemNumber() int {
+ cnt, err := q.baseQueue.Len(q.ctxRun)
+ if err != nil {
+ log.Error("Failed to get number of items in queue %q: %v", q.GetName(), err)
+ }
+ return cnt
+}
+
+func (q *WorkerPoolQueue[T]) FlushWithContext(ctx context.Context, timeout time.Duration) (err error) {
+ if q.isBaseQueueDummy() {
+ return nil
+ }
+
+ log.Debug("Try to flush queue %q with timeout %v", q.GetName(), timeout)
+ defer log.Debug("Finish flushing queue %q, err: %v", q.GetName(), err)
+
+ var after <-chan time.Time
+ after = infiniteTimerC
+ if timeout > 0 {
+ after = time.After(timeout)
+ }
+ c := make(flushType)
+
+ // send flush request
+ // if it blocks, it means that there is a flush in progress or the queue hasn't been started yet
+ select {
+ case q.flushChan <- c:
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-q.ctxRun.Done():
+ return q.ctxRun.Err()
+ case <-after:
+ return context.DeadlineExceeded
+ }
+
+ // wait for flush to finish
+ select {
+ case <-c:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-q.ctxRun.Done():
+ return q.ctxRun.Err()
+ case <-after:
+ return context.DeadlineExceeded
+ }
+}
+
+// RemoveAllItems removes all items in the baes queue
+func (q *WorkerPoolQueue[T]) RemoveAllItems(ctx context.Context) error {
+ return q.baseQueue.RemoveAll(ctx)
+}
+
+func (q *WorkerPoolQueue[T]) marshal(data T) []byte {
+ bs, err := json.Marshal(data)
+ if err != nil {
+ log.Error("Failed to marshal item for queue %q: %v", q.GetName(), err)
+ return nil
+ }
+ return bs
+}
+
+func (q *WorkerPoolQueue[T]) unmarshal(data []byte) (t T, ok bool) {
+ if err := json.Unmarshal(data, &t); err != nil {
+ log.Error("Failed to unmarshal item from queue %q: %v", q.GetName(), err)
+ return t, false
+ }
+ return t, true
+}
+
+func (q *WorkerPoolQueue[T]) isBaseQueueDummy() bool {
+ _, isDummy := q.baseQueue.(*baseDummy)
+ return isDummy
+}
+
+// Push adds an item to the queue, it may block for a while and then returns an error if the queue is full
+func (q *WorkerPoolQueue[T]) Push(data T) error {
+ if q.isBaseQueueDummy() && q.safeHandler != nil {
+ // FIXME: the "immediate" queue is only for testing, but it really causes problems because its behavior is different from a real queue.
+ // Even if tests pass, it doesn't mean that there is no bug in code.
+ if data, ok := q.unmarshal(q.marshal(data)); ok {
+ q.safeHandler(data)
+ }
+ }
+ return q.baseQueue.PushItem(q.ctxRun, q.marshal(data))
+}
+
+// Has only works for unique queues. Keep in mind that this check may not be reliable (due to lacking of proper transaction support)
+// There could be a small chance that duplicate items appear in the queue
+func (q *WorkerPoolQueue[T]) Has(data T) (bool, error) {
+ return q.baseQueue.HasItem(q.ctxRun, q.marshal(data))
+}
+
+func (q *WorkerPoolQueue[T]) Run() {
+ q.doRun()
+}
+
+func (q *WorkerPoolQueue[T]) Cancel() {
+ q.ctxRunCancel()
+}
+
+// ShutdownWait shuts down the queue, waits for all workers to finish their jobs, and pushes the unhandled items back to the base queue
+// It waits for all workers (handlers) to finish their jobs, in case some buggy handlers would hang forever, a reasonable timeout is needed
+func (q *WorkerPoolQueue[T]) ShutdownWait(timeout time.Duration) {
+ q.shutdownTimeout.Store(int64(timeout))
+ q.ctxRunCancel()
+ <-q.shutdownDone
+}
+
+func getNewQueue(t string, cfg *BaseConfig, unique bool) (string, baseQueue, error) {
+ switch t {
+ case "dummy", "immediate":
+ queue, err := newBaseDummy(cfg, unique)
+
+ return t, queue, err
+ case "channel":
+ queue, err := newBaseChannelGeneric(cfg, unique)
+
+ return t, queue, err
+ case "redis":
+ queue, err := newBaseRedisGeneric(cfg, unique, nil)
+
+ return t, queue, err
+ default: // level(leveldb,levelqueue,persistable-channel)
+ queue, err := newBaseLevelQueueGeneric(cfg, unique)
+
+ return "level", queue, err
+ }
+}
+
+func newWorkerPoolQueueForTest[T any](name string, queueSetting setting.QueueSettings, handler HandlerFuncT[T], unique bool) (*WorkerPoolQueue[T], error) {
+ return NewWorkerPoolQueueWithContext(context.Background(), name, queueSetting, handler, unique)
+}
+
+func NewWorkerPoolQueueWithContext[T any](ctx context.Context, name string, queueSetting setting.QueueSettings, handler HandlerFuncT[T], unique bool) (*WorkerPoolQueue[T], error) {
+ if handler == nil {
+ log.Debug("Use dummy queue for %q because handler is nil and caller doesn't want to process the queue items", name)
+ queueSetting.Type = "dummy"
+ }
+
+ var w WorkerPoolQueue[T]
+ var err error
+
+ w.baseConfig = toBaseConfig(name, queueSetting)
+
+ w.baseQueueType, w.baseQueue, err = getNewQueue(queueSetting.Type, w.baseConfig, unique)
+ if err != nil {
+ return nil, err
+ }
+ log.Trace("Created queue %q of type %q", name, w.baseQueueType)
+
+ w.ctxRun, _, w.ctxRunCancel = process.GetManager().AddTypedContext(ctx, "Queue: "+w.GetName(), process.SystemProcessType, false)
+ w.batchChan = make(chan []T)
+ w.flushChan = make(chan flushType)
+ w.shutdownDone = make(chan struct{})
+ w.shutdownTimeout.Store(int64(shutdownDefaultTimeout))
+ w.workerMaxNum = queueSetting.MaxWorkers
+ w.batchLength = queueSetting.BatchLength
+
+ w.origHandler = handler
+ w.safeHandler = func(t ...T) (unhandled []T) {
+ defer func() {
+ err := recover()
+ if err != nil {
+ log.Error("Recovered from panic in queue %q handler: %v\n%s", name, err, log.Stack(2))
+ }
+ }()
+ if w.origHandler != nil {
+ return w.origHandler(t...)
+ }
+ return nil
+ }
+
+ return &w, nil
+}
diff --git a/modules/queue/workerqueue_test.go b/modules/queue/workerqueue_test.go
new file mode 100644
index 0000000..4cfe8ed
--- /dev/null
+++ b/modules/queue/workerqueue_test.go
@@ -0,0 +1,291 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package queue
+
+import (
+ "bytes"
+ "context"
+ "runtime"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func runWorkerPoolQueue[T any](q *WorkerPoolQueue[T]) func() {
+ go q.Run()
+ return func() {
+ q.ShutdownWait(1 * time.Second)
+ }
+}
+
+func TestWorkerPoolQueueUnhandled(t *testing.T) {
+ oldUnhandledItemRequeueDuration := unhandledItemRequeueDuration.Load()
+ unhandledItemRequeueDuration.Store(0)
+ defer unhandledItemRequeueDuration.Store(oldUnhandledItemRequeueDuration)
+
+ mu := sync.Mutex{}
+
+ test := func(t *testing.T, queueSetting setting.QueueSettings) {
+ queueSetting.Length = 100
+ queueSetting.Type = "channel"
+ queueSetting.Datadir = t.TempDir() + "/test-queue"
+ m := map[int]int{}
+
+ // odds are handled once, evens are handled twice
+ handler := func(items ...int) (unhandled []int) {
+ testRecorder.Record("handle:%v", items)
+ for _, item := range items {
+ mu.Lock()
+ if item%2 == 0 && m[item] == 0 {
+ unhandled = append(unhandled, item)
+ }
+ m[item]++
+ mu.Unlock()
+ }
+ return unhandled
+ }
+
+ q, _ := newWorkerPoolQueueForTest("test-workpoolqueue", queueSetting, handler, false)
+ stop := runWorkerPoolQueue(q)
+ for i := 0; i < queueSetting.Length; i++ {
+ testRecorder.Record("push:%v", i)
+ require.NoError(t, q.Push(i))
+ }
+ require.NoError(t, q.FlushWithContext(context.Background(), 0))
+ stop()
+
+ ok := true
+ for i := 0; i < queueSetting.Length; i++ {
+ if i%2 == 0 {
+ ok = ok && assert.EqualValues(t, 2, m[i], "test %s: item %d", t.Name(), i)
+ } else {
+ ok = ok && assert.EqualValues(t, 1, m[i], "test %s: item %d", t.Name(), i)
+ }
+ }
+ if !ok {
+ t.Logf("m: %v", m)
+ t.Logf("records: %v", testRecorder.Records())
+ }
+ testRecorder.Reset()
+ }
+
+ runCount := 2 // we can run these tests even hundreds times to see its stability
+ t.Run("1/1", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ test(t, setting.QueueSettings{BatchLength: 1, MaxWorkers: 1})
+ }
+ })
+ t.Run("3/1", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ test(t, setting.QueueSettings{BatchLength: 3, MaxWorkers: 1})
+ }
+ })
+ t.Run("4/5", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ test(t, setting.QueueSettings{BatchLength: 4, MaxWorkers: 5})
+ }
+ })
+}
+
+func TestWorkerPoolQueuePersistence(t *testing.T) {
+ runCount := 2 // we can run these tests even hundreds times to see its stability
+ t.Run("1/1", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ testWorkerPoolQueuePersistence(t, setting.QueueSettings{BatchLength: 1, MaxWorkers: 1, Length: 100})
+ }
+ })
+ t.Run("3/1", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ testWorkerPoolQueuePersistence(t, setting.QueueSettings{BatchLength: 3, MaxWorkers: 1, Length: 100})
+ }
+ })
+ t.Run("4/5", func(t *testing.T) {
+ for i := 0; i < runCount; i++ {
+ testWorkerPoolQueuePersistence(t, setting.QueueSettings{BatchLength: 4, MaxWorkers: 5, Length: 100})
+ }
+ })
+}
+
+func testWorkerPoolQueuePersistence(t *testing.T, queueSetting setting.QueueSettings) {
+ testCount := queueSetting.Length
+ queueSetting.Type = "level"
+ queueSetting.Datadir = t.TempDir() + "/test-queue"
+
+ mu := sync.Mutex{}
+
+ var tasksQ1, tasksQ2 []string
+ q1 := func() {
+ startWhenAllReady := make(chan struct{}) // only start data consuming when the "testCount" tasks are all pushed into queue
+ stopAt20Shutdown := make(chan struct{}) // stop and shutdown at the 20th item
+
+ testHandler := func(data ...string) []string {
+ <-startWhenAllReady
+ time.Sleep(10 * time.Millisecond)
+ for _, s := range data {
+ mu.Lock()
+ tasksQ1 = append(tasksQ1, s)
+ mu.Unlock()
+
+ if s == "task-20" {
+ close(stopAt20Shutdown)
+ }
+ }
+ return nil
+ }
+
+ q, _ := newWorkerPoolQueueForTest("pr_patch_checker_test", queueSetting, testHandler, true)
+ stop := runWorkerPoolQueue(q)
+ for i := 0; i < testCount; i++ {
+ _ = q.Push("task-" + strconv.Itoa(i))
+ }
+ close(startWhenAllReady)
+ <-stopAt20Shutdown // it's possible to have more than 20 tasks executed
+ stop()
+ }
+
+ q1() // run some tasks and shutdown at an intermediate point
+
+ time.Sleep(100 * time.Millisecond) // because the handler in q1 has a slight delay, we need to wait for it to finish
+
+ q2 := func() {
+ testHandler := func(data ...string) []string {
+ for _, s := range data {
+ mu.Lock()
+ tasksQ2 = append(tasksQ2, s)
+ mu.Unlock()
+ }
+ return nil
+ }
+
+ q, _ := newWorkerPoolQueueForTest("pr_patch_checker_test", queueSetting, testHandler, true)
+ stop := runWorkerPoolQueue(q)
+ require.NoError(t, q.FlushWithContext(context.Background(), 0))
+ stop()
+ }
+
+ q2() // restart the queue to continue to execute the tasks in it
+
+ assert.NotEmpty(t, tasksQ1)
+ assert.NotEmpty(t, tasksQ2)
+ assert.EqualValues(t, testCount, len(tasksQ1)+len(tasksQ2))
+}
+
+func TestWorkerPoolQueueActiveWorkers(t *testing.T) {
+ defer test.MockVariableValue(&workerIdleDuration, 300*time.Millisecond)()
+
+ handler := func(items ...int) (unhandled []int) {
+ time.Sleep(100 * time.Millisecond)
+ return nil
+ }
+
+ q, _ := newWorkerPoolQueueForTest("test-workpoolqueue", setting.QueueSettings{Type: "channel", BatchLength: 1, MaxWorkers: 1, Length: 100}, handler, false)
+ stop := runWorkerPoolQueue(q)
+ for i := 0; i < 5; i++ {
+ require.NoError(t, q.Push(i))
+ }
+
+ time.Sleep(50 * time.Millisecond)
+ assert.EqualValues(t, 1, q.GetWorkerNumber())
+ assert.EqualValues(t, 1, q.GetWorkerActiveNumber())
+ time.Sleep(500 * time.Millisecond)
+ assert.EqualValues(t, 1, q.GetWorkerNumber())
+ assert.EqualValues(t, 0, q.GetWorkerActiveNumber())
+ time.Sleep(workerIdleDuration)
+ assert.EqualValues(t, 1, q.GetWorkerNumber()) // there is at least one worker after the queue begins working
+ stop()
+
+ q, _ = newWorkerPoolQueueForTest("test-workpoolqueue", setting.QueueSettings{Type: "channel", BatchLength: 1, MaxWorkers: 3, Length: 100}, handler, false)
+ stop = runWorkerPoolQueue(q)
+ for i := 0; i < 15; i++ {
+ require.NoError(t, q.Push(i))
+ }
+
+ time.Sleep(50 * time.Millisecond)
+ assert.EqualValues(t, 3, q.GetWorkerNumber())
+ assert.EqualValues(t, 3, q.GetWorkerActiveNumber())
+ time.Sleep(500 * time.Millisecond)
+ assert.EqualValues(t, 3, q.GetWorkerNumber())
+ assert.EqualValues(t, 0, q.GetWorkerActiveNumber())
+ time.Sleep(workerIdleDuration)
+ assert.EqualValues(t, 1, q.GetWorkerNumber()) // there is at least one worker after the queue begins working
+ stop()
+}
+
+func TestWorkerPoolQueueShutdown(t *testing.T) {
+ oldUnhandledItemRequeueDuration := unhandledItemRequeueDuration.Load()
+ unhandledItemRequeueDuration.Store(int64(100 * time.Millisecond))
+ defer unhandledItemRequeueDuration.Store(oldUnhandledItemRequeueDuration)
+
+ // simulate a slow handler, it doesn't handle any item (all items will be pushed back to the queue)
+ handlerCalled := make(chan struct{})
+ handler := func(items ...int) (unhandled []int) {
+ if items[0] == 0 {
+ close(handlerCalled)
+ }
+ time.Sleep(400 * time.Millisecond)
+ return items
+ }
+
+ qs := setting.QueueSettings{Type: "level", Datadir: t.TempDir() + "/queue", BatchLength: 3, MaxWorkers: 4, Length: 20}
+ q, _ := newWorkerPoolQueueForTest("test-workpoolqueue", qs, handler, false)
+ stop := runWorkerPoolQueue(q)
+ for i := 0; i < qs.Length; i++ {
+ require.NoError(t, q.Push(i))
+ }
+ <-handlerCalled
+ time.Sleep(200 * time.Millisecond) // wait for a while to make sure all workers are active
+ assert.EqualValues(t, 4, q.GetWorkerActiveNumber())
+ stop() // stop triggers shutdown
+ assert.EqualValues(t, 0, q.GetWorkerActiveNumber())
+
+ // no item was ever handled, so we still get all of them again
+ q, _ = newWorkerPoolQueueForTest("test-workpoolqueue", qs, handler, false)
+ assert.EqualValues(t, 20, q.GetQueueItemNumber())
+}
+
+func TestWorkerPoolQueueWorkerIdleReset(t *testing.T) {
+ defer test.MockVariableValue(&workerIdleDuration, 1*time.Millisecond)()
+
+ chGoroutineIDs := make(chan string)
+ handler := func(items ...int) (unhandled []int) {
+ time.Sleep(10 * workerIdleDuration)
+ chGoroutineIDs <- goroutineID() // hacky way to identify a worker
+ return nil
+ }
+
+ q, _ := newWorkerPoolQueueForTest("test-workpoolqueue", setting.QueueSettings{Type: "channel", BatchLength: 1, MaxWorkers: 2, Length: 100}, handler, false)
+ stop := runWorkerPoolQueue(q)
+
+ const workloadSize = 12
+ for i := 0; i < workloadSize; i++ {
+ require.NoError(t, q.Push(i))
+ }
+
+ workerIDs := make(map[string]struct{})
+ for i := 0; i < workloadSize; i++ {
+ c := <-chGoroutineIDs
+ workerIDs[c] = struct{}{}
+ t.Logf("%d workers: overall=%d current=%d", i, len(workerIDs), q.GetWorkerNumber())
+
+ // ensure that no more than qs.MaxWorkers workers are created over the whole lifetime of the queue
+ // (otherwise it would mean that some workers got shut down while the queue was full)
+ require.LessOrEqual(t, len(workerIDs), q.GetWorkerMaxNumber())
+ }
+ close(chGoroutineIDs)
+
+ stop()
+}
+
+func goroutineID() string {
+ var buffer [31]byte
+ _ = runtime.Stack(buffer[:], false)
+ return string(bytes.Fields(buffer[10:])[0])
+}
diff --git a/modules/recaptcha/recaptcha.go b/modules/recaptcha/recaptcha.go
new file mode 100644
index 0000000..1777d16
--- /dev/null
+++ b/modules/recaptcha/recaptcha.go
@@ -0,0 +1,90 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package recaptcha
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Response is the structure of JSON returned from API
+type Response struct {
+ Success bool `json:"success"`
+ ChallengeTS string `json:"challenge_ts"`
+ Hostname string `json:"hostname"`
+ ErrorCodes []ErrorCode `json:"error-codes"`
+}
+
+const apiURL = "api/siteverify"
+
+// Verify calls Google Recaptcha API to verify token
+func Verify(ctx context.Context, response string) (bool, error) {
+ post := url.Values{
+ "secret": {setting.Service.RecaptchaSecret},
+ "response": {response},
+ }
+ // Basically a copy of http.PostForm, but with a context
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ util.URLJoin(setting.Service.RecaptchaURL, apiURL), strings.NewReader(post.Encode()))
+ if err != nil {
+ return false, fmt.Errorf("Failed to create CAPTCHA request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return false, fmt.Errorf("Failed to send CAPTCHA response: %s", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, fmt.Errorf("Failed to read CAPTCHA response: %s", err)
+ }
+
+ var jsonResponse Response
+ err = json.Unmarshal(body, &jsonResponse)
+ if err != nil {
+ return false, fmt.Errorf("Failed to parse CAPTCHA response: %s", err)
+ }
+ var respErr error
+ if len(jsonResponse.ErrorCodes) > 0 {
+ respErr = jsonResponse.ErrorCodes[0]
+ }
+ return jsonResponse.Success, respErr
+}
+
+// ErrorCode is a reCaptcha error
+type ErrorCode string
+
+// String fulfills the Stringer interface
+func (e ErrorCode) String() string {
+ switch e {
+ case "missing-input-secret":
+ return "The secret parameter is missing."
+ case "invalid-input-secret":
+ return "The secret parameter is invalid or malformed."
+ case "missing-input-response":
+ return "The response parameter is missing."
+ case "invalid-input-response":
+ return "The response parameter is invalid or malformed."
+ case "bad-request":
+ return "The request is invalid or malformed."
+ case "timeout-or-duplicate":
+ return "The response is no longer valid: either is too old or has been used previously."
+ }
+ return string(e)
+}
+
+// Error fulfills the error interface
+func (e ErrorCode) Error() string {
+ return e.String()
+}
diff --git a/modules/references/references.go b/modules/references/references.go
new file mode 100644
index 0000000..c61d06d
--- /dev/null
+++ b/modules/references/references.go
@@ -0,0 +1,594 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package references
+
+import (
+ "bytes"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup/mdstripper"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+var (
+ // validNamePattern performs only the most basic validation for user or repository names
+ // Repository name should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.
+ validNamePattern = regexp.MustCompile(`^[a-z0-9_.-]+$`)
+
+ // NOTE: All below regex matching do not perform any extra validation.
+ // Thus a link is produced even if the linked entity does not exist.
+ // While fast, this is also incorrect and lead to false positives.
+ // TODO: fix invalid linking issue
+
+ // mentionPattern matches all mentions in the form of "@user" or "@org/team"
+ mentionPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_]+\/?[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_][0-9a-zA-Z-_.]+\/?[0-9a-zA-Z-_.]+[0-9a-zA-Z-_])(?:'|\s|[:,;.?!]\s|[:,;.?!]?$|\)|\])`)
+ // issueNumericPattern matches string that references to a numeric issue, e.g. #1287
+ issueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[|\'|\")([#!][0-9]+)(?:\s|$|\)|\]|\'|\"|[:;,.?!]\s|[:;,.?!]$)`)
+ // issueAlphanumericPattern matches string that references to an alphanumeric issue, e.g. ABC-1234
+ issueAlphanumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[|\"|\')([A-Z]{1,10}-[1-9][0-9]*)(?:\s|$|\)|\]|:|\.(\s|$)|\"|\')`)
+ // crossReferenceIssueNumericPattern matches string that references a numeric issue in a different repository
+ // e.g. org/repo#12345
+ crossReferenceIssueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+[#!][0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
+ // crossReferenceCommitPattern matches a string that references a commit in a different repository
+ // e.g. go-gitea/gitea@d8a994ef, go-gitea/gitea@d8a994ef243349f321568f9e36d5c3f444b99cae (7-40 characters)
+ crossReferenceCommitPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+)/([0-9a-zA-Z-_\.]+)@([0-9a-f]{7,64})(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
+ // spaceTrimmedPattern let's find the trailing space
+ spaceTrimmedPattern = regexp.MustCompile(`(?:.*[0-9a-zA-Z-_])\s`)
+ // timeLogPattern matches string for time tracking
+ timeLogPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@([0-9]+([\.,][0-9]+)?(w|d|m|h))+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
+
+ issueCloseKeywordsPat, issueReopenKeywordsPat *regexp.Regexp
+ issueKeywordsOnce sync.Once
+
+ giteaHostInit sync.Once
+ giteaHost string
+ giteaIssuePullPattern *regexp.Regexp
+
+ actionStrings = []string{
+ "none",
+ "closes",
+ "reopens",
+ "neutered",
+ }
+)
+
+// XRefAction represents the kind of effect a cross reference has once is resolved
+type XRefAction int64
+
+const (
+ // XRefActionNone means the cross-reference is simply a comment
+ XRefActionNone XRefAction = iota // 0
+ // XRefActionCloses means the cross-reference should close an issue if it is resolved
+ XRefActionCloses // 1
+ // XRefActionReopens means the cross-reference should reopen an issue if it is resolved
+ XRefActionReopens // 2
+ // XRefActionNeutered means the cross-reference will no longer affect the source
+ XRefActionNeutered // 3
+)
+
+func (a XRefAction) String() string {
+ return actionStrings[a]
+}
+
+// IssueReference contains an unverified cross-reference to a local issue or pull request
+type IssueReference struct {
+ Index int64
+ Owner string
+ Name string
+ Action XRefAction
+ TimeLog string
+}
+
+// RenderizableReference contains an unverified cross-reference to with rendering information
+// The IsPull member means that a `!num` reference was used instead of `#num`.
+// This kind of reference is used to make pulls available when an external issue tracker
+// is used. Otherwise, `#` and `!` are completely interchangeable.
+type RenderizableReference struct {
+ Issue string
+ Owner string
+ Name string
+ CommitSha string
+ IsPull bool
+ RefLocation *RefSpan
+ Action XRefAction
+ ActionLocation *RefSpan
+}
+
+type rawReference struct {
+ index int64
+ owner string
+ name string
+ isPull bool
+ action XRefAction
+ issue string
+ refLocation *RefSpan
+ actionLocation *RefSpan
+ timeLog string
+}
+
+func rawToIssueReferenceList(reflist []*rawReference) []IssueReference {
+ refarr := make([]IssueReference, len(reflist))
+ for i, r := range reflist {
+ refarr[i] = IssueReference{
+ Index: r.index,
+ Owner: r.owner,
+ Name: r.name,
+ Action: r.action,
+ TimeLog: r.timeLog,
+ }
+ }
+ return refarr
+}
+
+// RefSpan is the position where the reference was found within the parsed text
+type RefSpan struct {
+ Start int
+ End int
+}
+
+func makeKeywordsPat(words []string) *regexp.Regexp {
+ acceptedWords := parseKeywords(words)
+ if len(acceptedWords) == 0 {
+ // Never match
+ return nil
+ }
+ return regexp.MustCompile(`(?i)(?:\s|^|\(|\[)(` + strings.Join(acceptedWords, `|`) + `):? $`)
+}
+
+func parseKeywords(words []string) []string {
+ acceptedWords := make([]string, 0, 5)
+ wordPat := regexp.MustCompile(`^[\pL]+$`)
+ for _, word := range words {
+ word = strings.ToLower(strings.TrimSpace(word))
+ // Accept Unicode letter class runes (a-z, á, à, ä, )
+ if wordPat.MatchString(word) {
+ acceptedWords = append(acceptedWords, word)
+ } else {
+ log.Info("Invalid keyword: %s", word)
+ }
+ }
+ return acceptedWords
+}
+
+func newKeywords() {
+ issueKeywordsOnce.Do(func() {
+ // Delay initialization until after the settings module is initialized
+ doNewKeywords(setting.Repository.PullRequest.CloseKeywords, setting.Repository.PullRequest.ReopenKeywords)
+ })
+}
+
+func doNewKeywords(close, reopen []string) {
+ issueCloseKeywordsPat = makeKeywordsPat(close)
+ issueReopenKeywordsPat = makeKeywordsPat(reopen)
+}
+
+// getGiteaHostName returns a normalized string with the local host name, with no scheme or port information
+func getGiteaHostName() string {
+ giteaHostInit.Do(func() {
+ if uapp, err := url.Parse(setting.AppURL); err == nil {
+ giteaHost = strings.ToLower(uapp.Host)
+ giteaIssuePullPattern = regexp.MustCompile(
+ `(\s|^|\(|\[)` +
+ regexp.QuoteMeta(strings.TrimSpace(setting.AppURL)) +
+ `([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+)/` +
+ `((?:issues)|(?:pulls))/([0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
+ } else {
+ giteaHost = ""
+ giteaIssuePullPattern = nil
+ }
+ })
+ return giteaHost
+}
+
+// getGiteaIssuePullPattern
+func getGiteaIssuePullPattern() *regexp.Regexp {
+ getGiteaHostName()
+ return giteaIssuePullPattern
+}
+
+// FindAllMentionsMarkdown matches mention patterns in given content and
+// returns a list of found unvalidated user names **not including** the @ prefix.
+func FindAllMentionsMarkdown(content string) []string {
+ bcontent, _ := mdstripper.StripMarkdownBytes([]byte(content))
+ locations := FindAllMentionsBytes(bcontent)
+ mentions := make([]string, len(locations))
+ for i, val := range locations {
+ mentions[i] = string(bcontent[val.Start+1 : val.End])
+ }
+ return mentions
+}
+
+// FindAllMentionsBytes matches mention patterns in given content
+// and returns a list of locations for the unvalidated user names, including the @ prefix.
+func FindAllMentionsBytes(content []byte) []RefSpan {
+ // Sadly we can't use FindAllSubmatchIndex because our pattern checks for starting and
+ // trailing spaces (\s@mention,\s), so if we get two consecutive references, the space
+ // from the second reference will be "eaten" by the first one:
+ // ...\s@mention1\s@mention2\s... --> ...`\s@mention1\s`, (not) `@mention2,\s...`
+ ret := make([]RefSpan, 0, 5)
+ pos := 0
+ for {
+ match := mentionPattern.FindSubmatchIndex(content[pos:])
+ if match == nil {
+ break
+ }
+ ret = append(ret, RefSpan{Start: match[2] + pos, End: match[3] + pos})
+ notrail := spaceTrimmedPattern.FindSubmatchIndex(content[match[2]+pos : match[3]+pos])
+ if notrail == nil {
+ pos = match[3] + pos
+ } else {
+ pos = match[3] + pos + notrail[1] - notrail[3]
+ }
+ }
+ return ret
+}
+
+// FindFirstMentionBytes matches the first mention in then given content
+// and returns the location of the unvalidated user name, including the @ prefix.
+func FindFirstMentionBytes(content []byte) (bool, RefSpan) {
+ mention := mentionPattern.FindSubmatchIndex(content)
+ if mention == nil {
+ return false, RefSpan{}
+ }
+ return true, RefSpan{Start: mention[2], End: mention[3]}
+}
+
+// FindAllIssueReferencesMarkdown strips content from markdown markup
+// and returns a list of unvalidated references found in it.
+func FindAllIssueReferencesMarkdown(content string) []IssueReference {
+ return rawToIssueReferenceList(findAllIssueReferencesMarkdown(content))
+}
+
+func findAllIssueReferencesMarkdown(content string) []*rawReference {
+ bcontent, links := mdstripper.StripMarkdownBytes([]byte(content))
+ return findAllIssueReferencesBytes(bcontent, links)
+}
+
+func convertFullHTMLReferencesToShortRefs(re *regexp.Regexp, contentBytes *[]byte) {
+ // We will iterate through the content, rewrite and simplify full references.
+ //
+ // We want to transform something like:
+ //
+ // this is a https://ourgitea.com/git/owner/repo/issues/123456789, foo
+ // https://ourgitea.com/git/owner/repo/pulls/123456789
+ //
+ // Into something like:
+ //
+ // this is a #123456789, foo
+ // !123456789
+
+ pos := 0
+ for {
+ // re looks for something like: (\s|^|\(|\[)https://ourgitea.com/git/(owner/repo)/(issues)/(123456789)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)
+ match := re.FindSubmatchIndex((*contentBytes)[pos:])
+ if match == nil {
+ break
+ }
+ // match is a bunch of indices into the content from pos onwards so
+ // to simplify things let's just add pos to all of the indices in match
+ for i := range match {
+ match[i] += pos
+ }
+
+ // match[0]-match[1] is whole string
+ // match[2]-match[3] is preamble
+
+ // move the position to the end of the preamble
+ pos = match[3]
+
+ // match[4]-match[5] is owner/repo
+ // now copy the owner/repo to end of the preamble
+ endPos := pos + match[5] - match[4]
+ copy((*contentBytes)[pos:endPos], (*contentBytes)[match[4]:match[5]])
+
+ // move the current position to the end of the newly copied owner/repo
+ pos = endPos
+
+ // Now set the issue/pull marker:
+ //
+ // match[6]-match[7] == 'issues'
+ (*contentBytes)[pos] = '#'
+ if string((*contentBytes)[match[6]:match[7]]) == "pulls" {
+ (*contentBytes)[pos] = '!'
+ }
+ pos++
+
+ // Then add the issue/pull number
+ //
+ // match[8]-match[9] is the number
+ endPos = pos + match[9] - match[8]
+ copy((*contentBytes)[pos:endPos], (*contentBytes)[match[8]:match[9]])
+
+ // Now copy what's left at the end of the string to the new end position
+ copy((*contentBytes)[endPos:], (*contentBytes)[match[9]:])
+ // now we reset the length
+
+ // our new section has length endPos - match[3]
+ // our old section has length match[9] - match[3]
+ *contentBytes = (*contentBytes)[:len(*contentBytes)-match[9]+endPos]
+ pos = endPos
+ }
+}
+
+// FindAllIssueReferences returns a list of unvalidated references found in a string.
+func FindAllIssueReferences(content string) []IssueReference {
+ // Need to convert fully qualified html references to local system to #/! short codes
+ contentBytes := []byte(content)
+ if re := getGiteaIssuePullPattern(); re != nil {
+ convertFullHTMLReferencesToShortRefs(re, &contentBytes)
+ } else {
+ log.Debug("No GiteaIssuePullPattern pattern")
+ }
+ return rawToIssueReferenceList(findAllIssueReferencesBytes(contentBytes, []string{}))
+}
+
+// FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string.
+func FindRenderizableReferenceNumeric(content string, prOnly, crossLinkOnly bool) (bool, *RenderizableReference) {
+ var match []int
+ if !crossLinkOnly {
+ match = issueNumericPattern.FindStringSubmatchIndex(content)
+ }
+ if match == nil {
+ if match = crossReferenceIssueNumericPattern.FindStringSubmatchIndex(content); match == nil {
+ return false, nil
+ }
+ }
+ r := getCrossReference(util.UnsafeStringToBytes(content), match[2], match[3], false, prOnly)
+ if r == nil {
+ return false, nil
+ }
+
+ return true, &RenderizableReference{
+ Issue: r.issue,
+ Owner: r.owner,
+ Name: r.name,
+ IsPull: r.isPull,
+ RefLocation: r.refLocation,
+ Action: r.action,
+ ActionLocation: r.actionLocation,
+ }
+}
+
+// FindRenderizableCommitCrossReference returns the first unvalidated commit cross reference found in a string.
+func FindRenderizableCommitCrossReference(content string) (bool, *RenderizableReference) {
+ m := crossReferenceCommitPattern.FindStringSubmatchIndex(content)
+ if len(m) < 8 {
+ return false, nil
+ }
+
+ return true, &RenderizableReference{
+ Owner: content[m[2]:m[3]],
+ Name: content[m[4]:m[5]],
+ CommitSha: content[m[6]:m[7]],
+ RefLocation: &RefSpan{Start: m[2], End: m[7]},
+ }
+}
+
+// FindRenderizableReferenceRegexp returns the first regexp unvalidated references found in a string.
+func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bool, *RenderizableReference) {
+ match := pattern.FindStringSubmatchIndex(content)
+ if len(match) < 4 {
+ return false, nil
+ }
+
+ action, location := findActionKeywords([]byte(content), match[2])
+
+ return true, &RenderizableReference{
+ Issue: content[match[2]:match[3]],
+ RefLocation: &RefSpan{Start: match[0], End: match[1]},
+ Action: action,
+ ActionLocation: location,
+ IsPull: false,
+ }
+}
+
+// FindRenderizableReferenceAlphanumeric returns the first alphanumeric unvalidated references found in a string.
+func FindRenderizableReferenceAlphanumeric(content string) (bool, *RenderizableReference) {
+ match := issueAlphanumericPattern.FindStringSubmatchIndex(content)
+ if match == nil {
+ return false, nil
+ }
+
+ action, location := findActionKeywords([]byte(content), match[2])
+
+ return true, &RenderizableReference{
+ Issue: content[match[2]:match[3]],
+ RefLocation: &RefSpan{Start: match[2], End: match[3]},
+ Action: action,
+ ActionLocation: location,
+ IsPull: false,
+ }
+}
+
+// FindAllIssueReferencesBytes returns a list of unvalidated references found in a byte slice.
+func findAllIssueReferencesBytes(content []byte, links []string) []*rawReference {
+ ret := make([]*rawReference, 0, 10)
+ pos := 0
+
+ // Sadly we can't use FindAllSubmatchIndex because our pattern checks for starting and
+ // trailing spaces (\s#ref,\s), so if we get two consecutive references, the space
+ // from the second reference will be "eaten" by the first one:
+ // ...\s#ref1\s#ref2\s... --> ...`\s#ref1\s`, (not) `#ref2,\s...`
+ for {
+ match := issueNumericPattern.FindSubmatchIndex(content[pos:])
+ if match == nil {
+ break
+ }
+ if ref := getCrossReference(content, match[2]+pos, match[3]+pos, false, false); ref != nil {
+ ret = append(ret, ref)
+ }
+ notrail := spaceTrimmedPattern.FindSubmatchIndex(content[match[2]+pos : match[3]+pos])
+ if notrail == nil {
+ pos = match[3] + pos
+ } else {
+ pos = match[3] + pos + notrail[1] - notrail[3]
+ }
+ }
+
+ pos = 0
+
+ for {
+ match := crossReferenceIssueNumericPattern.FindSubmatchIndex(content[pos:])
+ if match == nil {
+ break
+ }
+ if ref := getCrossReference(content, match[2]+pos, match[3]+pos, false, false); ref != nil {
+ ret = append(ret, ref)
+ }
+ notrail := spaceTrimmedPattern.FindSubmatchIndex(content[match[2]+pos : match[3]+pos])
+ if notrail == nil {
+ pos = match[3] + pos
+ } else {
+ pos = match[3] + pos + notrail[1] - notrail[3]
+ }
+ }
+
+ localhost := getGiteaHostName()
+ for _, link := range links {
+ if u, err := url.Parse(link); err == nil {
+ // Note: we're not attempting to match the URL scheme (http/https)
+ host := strings.ToLower(u.Host)
+ if host != "" && host != localhost {
+ continue
+ }
+ parts := strings.Split(u.EscapedPath(), "/")
+ // /user/repo/issues/3
+ if len(parts) != 5 || parts[0] != "" {
+ continue
+ }
+ var sep string
+ if parts[3] == "issues" {
+ sep = "#"
+ } else if parts[3] == "pulls" {
+ sep = "!"
+ } else {
+ continue
+ }
+ // Note: closing/reopening keywords not supported with URLs
+ bytes := []byte(parts[1] + "/" + parts[2] + sep + parts[4])
+ if ref := getCrossReference(bytes, 0, len(bytes), true, false); ref != nil {
+ ref.refLocation = nil
+ ret = append(ret, ref)
+ }
+ }
+ }
+
+ if len(ret) == 0 {
+ return ret
+ }
+
+ pos = 0
+
+ for {
+ match := timeLogPattern.FindSubmatchIndex(content[pos:])
+ if match == nil {
+ break
+ }
+
+ timeLogEntry := string(content[match[2]+pos+1 : match[3]+pos])
+
+ var f *rawReference
+ for _, ref := range ret {
+ if ref.refLocation != nil && ref.refLocation.End < match[2]+pos && (f == nil || f.refLocation.End < ref.refLocation.End) {
+ f = ref
+ }
+ }
+
+ pos = match[1] + pos
+
+ if f == nil {
+ f = ret[0]
+ }
+
+ if len(f.timeLog) == 0 {
+ f.timeLog = timeLogEntry
+ }
+ }
+
+ return ret
+}
+
+func getCrossReference(content []byte, start, end int, fromLink, prOnly bool) *rawReference {
+ sep := bytes.IndexAny(content[start:end], "#!")
+ if sep < 0 {
+ return nil
+ }
+ isPull := content[start+sep] == '!'
+ if prOnly && !isPull {
+ return nil
+ }
+ repo := string(content[start : start+sep])
+ issue := string(content[start+sep+1 : end])
+ index, err := strconv.ParseInt(issue, 10, 64)
+ if err != nil {
+ return nil
+ }
+ if repo == "" {
+ if fromLink {
+ // Markdown links must specify owner/repo
+ return nil
+ }
+ action, location := findActionKeywords(content, start)
+ return &rawReference{
+ index: index,
+ action: action,
+ issue: issue,
+ isPull: isPull,
+ refLocation: &RefSpan{Start: start, End: end},
+ actionLocation: location,
+ }
+ }
+ parts := strings.Split(strings.ToLower(repo), "/")
+ if len(parts) != 2 {
+ return nil
+ }
+ owner, name := parts[0], parts[1]
+ if !validNamePattern.MatchString(owner) || !validNamePattern.MatchString(name) {
+ return nil
+ }
+ action, location := findActionKeywords(content, start)
+ return &rawReference{
+ index: index,
+ owner: owner,
+ name: name,
+ action: action,
+ issue: issue,
+ isPull: isPull,
+ refLocation: &RefSpan{Start: start, End: end},
+ actionLocation: location,
+ }
+}
+
+func findActionKeywords(content []byte, start int) (XRefAction, *RefSpan) {
+ newKeywords()
+ var m []int
+ if issueCloseKeywordsPat != nil {
+ m = issueCloseKeywordsPat.FindSubmatchIndex(content[:start])
+ if m != nil {
+ return XRefActionCloses, &RefSpan{Start: m[2], End: m[3]}
+ }
+ }
+ if issueReopenKeywordsPat != nil {
+ m = issueReopenKeywordsPat.FindSubmatchIndex(content[:start])
+ if m != nil {
+ return XRefActionReopens, &RefSpan{Start: m[2], End: m[3]}
+ }
+ }
+ return XRefActionNone, nil
+}
+
+// IsXrefActionable returns true if the xref action is actionable (i.e. produces a result when resolved)
+func IsXrefActionable(ref *RenderizableReference, extTracker bool) bool {
+ if extTracker {
+ // External issues cannot be automatically closed
+ return false
+ }
+ return ref.Action == XRefActionCloses || ref.Action == XRefActionReopens
+}
diff --git a/modules/references/references_test.go b/modules/references/references_test.go
new file mode 100644
index 0000000..ffa7f99
--- /dev/null
+++ b/modules/references/references_test.go
@@ -0,0 +1,563 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package references
+
+import (
+ "regexp"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type testFixture struct {
+ input string
+ expected []testResult
+}
+
+type testResult struct {
+ Index int64
+ Owner string
+ Name string
+ Issue string
+ IsPull bool
+ Action XRefAction
+ RefLocation *RefSpan
+ ActionLocation *RefSpan
+ TimeLog string
+}
+
+func TestConvertFullHTMLReferencesToShortRefs(t *testing.T) {
+ re := regexp.MustCompile(`(\s|^|\(|\[)` +
+ regexp.QuoteMeta("https://ourgitea.com/git/") +
+ `([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+)/` +
+ `((?:issues)|(?:pulls))/([0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
+ test := `this is a https://ourgitea.com/git/owner/repo/issues/123456789, foo
+https://ourgitea.com/git/owner/repo/pulls/123456789
+ And https://ourgitea.com/git/owner/repo/pulls/123
+`
+ expect := `this is a owner/repo#123456789, foo
+owner/repo!123456789
+ And owner/repo!123
+`
+
+ contentBytes := []byte(test)
+ convertFullHTMLReferencesToShortRefs(re, &contentBytes)
+ result := string(contentBytes)
+ assert.EqualValues(t, expect, result)
+}
+
+func TestFindAllIssueReferences(t *testing.T) {
+ fixtures := []testFixture{
+ {
+ "Simply closes: #29 yes",
+ []testResult{
+ {29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}, ""},
+ },
+ },
+ {
+ "Simply closes: !29 yes",
+ []testResult{
+ {29, "", "", "29", true, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}, ""},
+ },
+ },
+ {
+ " #124 yes, this is a reference.",
+ []testResult{
+ {124, "", "", "124", false, XRefActionNone, &RefSpan{Start: 0, End: 4}, nil, ""},
+ },
+ },
+ {
+ "```\nThis is a code block.\n#723 no, it's a code block.```",
+ []testResult{},
+ },
+ {
+ "This `#724` no, it's inline code.",
+ []testResult{},
+ },
+ {
+ "This org3/repo4#200 yes.",
+ []testResult{
+ {200, "org3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 19}, nil, ""},
+ },
+ },
+ {
+ "This org3/repo4!200 yes.",
+ []testResult{
+ {200, "org3", "repo4", "200", true, XRefActionNone, &RefSpan{Start: 5, End: 19}, nil, ""},
+ },
+ },
+ {
+ "This [one](#919) no, this is a URL fragment.",
+ []testResult{},
+ },
+ {
+ "This [two](/user2/repo1/issues/921) yes.",
+ []testResult{
+ {921, "user2", "repo1", "921", false, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "This [three](/user2/repo1/pulls/922) yes.",
+ []testResult{
+ {922, "user2", "repo1", "922", true, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "This [four](http://gitea.com:3000/org3/repo4/issues/203) yes.",
+ []testResult{
+ {203, "org3", "repo4", "203", false, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "This [five](http://github.com/org3/repo4/issues/204) no.",
+ []testResult{},
+ },
+ {
+ "This http://gitea.com:3000/user4/repo5/201 no, bad URL.",
+ []testResult{},
+ },
+ {
+ "This http://gitea.com:3000/user4/repo5/pulls/202 yes.",
+ []testResult{
+ {202, "user4", "repo5", "202", true, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "This http://gitea.com:3000/user4/repo5/pulls/202 yes. http://gitea.com:3000/user4/repo5/pulls/203 no",
+ []testResult{
+ {202, "user4", "repo5", "202", true, XRefActionNone, nil, nil, ""},
+ {203, "user4", "repo5", "203", true, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "This http://GiTeA.COM:3000/user4/repo6/pulls/205 yes.",
+ []testResult{
+ {205, "user4", "repo6", "205", true, XRefActionNone, nil, nil, ""},
+ },
+ },
+ {
+ "Reopens #15 yes",
+ []testResult{
+ {15, "", "", "15", false, XRefActionReopens, &RefSpan{Start: 8, End: 11}, &RefSpan{Start: 0, End: 7}, ""},
+ },
+ },
+ {
+ "This closes #20 for you yes",
+ []testResult{
+ {20, "", "", "20", false, XRefActionCloses, &RefSpan{Start: 12, End: 15}, &RefSpan{Start: 5, End: 11}, ""},
+ },
+ },
+ {
+ "Do you fix org6/repo6#300 ? yes",
+ []testResult{
+ {300, "org6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 11, End: 25}, &RefSpan{Start: 7, End: 10}, ""},
+ },
+ },
+ {
+ "For 999 #1235 no keyword, but yes",
+ []testResult{
+ {1235, "", "", "1235", false, XRefActionNone, &RefSpan{Start: 8, End: 13}, nil, ""},
+ },
+ },
+ {
+ "For [!123] yes",
+ []testResult{
+ {123, "", "", "123", true, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil, ""},
+ },
+ },
+ {
+ "For (#345) yes",
+ []testResult{
+ {345, "", "", "345", false, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil, ""},
+ },
+ },
+ {
+ "For #22,#23 no, neither #28:#29 or !30!31#32;33 should",
+ []testResult{},
+ },
+ {
+ "For #24, and #25. yes; also #26; #27? #28! and #29: should",
+ []testResult{
+ {24, "", "", "24", false, XRefActionNone, &RefSpan{Start: 4, End: 7}, nil, ""},
+ {25, "", "", "25", false, XRefActionNone, &RefSpan{Start: 13, End: 16}, nil, ""},
+ {26, "", "", "26", false, XRefActionNone, &RefSpan{Start: 28, End: 31}, nil, ""},
+ {27, "", "", "27", false, XRefActionNone, &RefSpan{Start: 33, End: 36}, nil, ""},
+ {28, "", "", "28", false, XRefActionNone, &RefSpan{Start: 38, End: 41}, nil, ""},
+ {29, "", "", "29", false, XRefActionNone, &RefSpan{Start: 47, End: 50}, nil, ""},
+ },
+ },
+ {
+ "This org3/repo4#200, yes.",
+ []testResult{
+ {200, "org3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 19}, nil, ""},
+ },
+ },
+ {
+ "Merge pull request '#12345 My fix for a bug' (!1337) from feature-branch into main",
+ []testResult{
+ {12345, "", "", "12345", false, XRefActionNone, &RefSpan{Start: 20, End: 26}, nil, ""},
+ {1337, "", "", "1337", true, XRefActionNone, &RefSpan{Start: 46, End: 51}, nil, ""},
+ },
+ },
+ {
+ "Which abc. #9434 same as above",
+ []testResult{
+ {9434, "", "", "9434", false, XRefActionNone, &RefSpan{Start: 11, End: 16}, nil, ""},
+ },
+ },
+ {
+ "This closes #600 and reopens #599",
+ []testResult{
+ {600, "", "", "600", false, XRefActionCloses, &RefSpan{Start: 12, End: 16}, &RefSpan{Start: 5, End: 11}, ""},
+ {599, "", "", "599", false, XRefActionReopens, &RefSpan{Start: 29, End: 33}, &RefSpan{Start: 21, End: 28}, ""},
+ },
+ },
+ {
+ "This fixes #100 spent @40m and reopens #101, also fixes #102 spent @4h15m",
+ []testResult{
+ {100, "", "", "100", false, XRefActionCloses, &RefSpan{Start: 11, End: 15}, &RefSpan{Start: 5, End: 10}, "40m"},
+ {101, "", "", "101", false, XRefActionReopens, &RefSpan{Start: 39, End: 43}, &RefSpan{Start: 31, End: 38}, ""},
+ {102, "", "", "102", false, XRefActionCloses, &RefSpan{Start: 56, End: 60}, &RefSpan{Start: 50, End: 55}, "4h15m"},
+ },
+ },
+ }
+
+ testFixtures(t, fixtures, "default")
+
+ type alnumFixture struct {
+ input string
+ issue string
+ refLocation *RefSpan
+ action XRefAction
+ actionLocation *RefSpan
+ }
+
+ alnumFixtures := []alnumFixture{
+ {
+ "This ref ABC-123 is alphanumeric",
+ "ABC-123", &RefSpan{Start: 9, End: 16},
+ XRefActionNone, nil,
+ },
+ {
+ "This closes ABCD-1234 alphanumeric",
+ "ABCD-1234", &RefSpan{Start: 12, End: 21},
+ XRefActionCloses, &RefSpan{Start: 5, End: 11},
+ },
+ }
+
+ for _, fixture := range alnumFixtures {
+ found, ref := FindRenderizableReferenceAlphanumeric(fixture.input)
+ if fixture.issue == "" {
+ assert.False(t, found, "Failed to parse: {%s}", fixture.input)
+ } else {
+ assert.True(t, found, "Failed to parse: {%s}", fixture.input)
+ assert.Equal(t, fixture.issue, ref.Issue, "Failed to parse: {%s}", fixture.input)
+ assert.Equal(t, fixture.refLocation, ref.RefLocation, "Failed to parse: {%s}", fixture.input)
+ assert.Equal(t, fixture.action, ref.Action, "Failed to parse: {%s}", fixture.input)
+ assert.Equal(t, fixture.actionLocation, ref.ActionLocation, "Failed to parse: {%s}", fixture.input)
+ }
+ }
+}
+
+func testFixtures(t *testing.T, fixtures []testFixture, context string) {
+ // Save original value for other tests that may rely on it
+ prevURL := setting.AppURL
+ setting.AppURL = "https://gitea.com:3000/"
+
+ for _, fixture := range fixtures {
+ expraw := make([]*rawReference, len(fixture.expected))
+ for i, e := range fixture.expected {
+ expraw[i] = &rawReference{
+ index: e.Index,
+ owner: e.Owner,
+ name: e.Name,
+ isPull: e.IsPull,
+ action: e.Action,
+ issue: e.Issue,
+ refLocation: e.RefLocation,
+ actionLocation: e.ActionLocation,
+ timeLog: e.TimeLog,
+ }
+ }
+ expref := rawToIssueReferenceList(expraw)
+ refs := FindAllIssueReferencesMarkdown(fixture.input)
+ assert.EqualValues(t, expref, refs, "[%s] Failed to parse: {%s}", context, fixture.input)
+ rawrefs := findAllIssueReferencesMarkdown(fixture.input)
+ assert.EqualValues(t, expraw, rawrefs, "[%s] Failed to parse: {%s}", context, fixture.input)
+ }
+
+ // Restore for other tests that may rely on the original value
+ setting.AppURL = prevURL
+}
+
+func TestFindAllMentions(t *testing.T) {
+ res := FindAllMentionsBytes([]byte("@tasha, @mike; @lucy: @john"))
+ assert.EqualValues(t, []RefSpan{
+ {Start: 0, End: 6},
+ {Start: 8, End: 13},
+ {Start: 15, End: 20},
+ {Start: 22, End: 27},
+ }, res)
+}
+
+func TestFindRenderizableCommitCrossReference(t *testing.T) {
+ cases := []struct {
+ Input string
+ Expected *RenderizableReference
+ }{
+ {
+ Input: "",
+ Expected: nil,
+ },
+ {
+ Input: "test",
+ Expected: nil,
+ },
+ {
+ Input: "go-gitea/gitea@test",
+ Expected: nil,
+ },
+ {
+ Input: "go-gitea/gitea@ab1234",
+ Expected: nil,
+ },
+ {
+ Input: "go-gitea/gitea@abcd1234",
+ Expected: &RenderizableReference{
+ Owner: "go-gitea",
+ Name: "gitea",
+ CommitSha: "abcd1234",
+ RefLocation: &RefSpan{Start: 0, End: 23},
+ },
+ },
+ {
+ Input: "go-gitea/gitea@abcd1234abcd1234abcd1234abcd1234abcd1234",
+ Expected: &RenderizableReference{
+ Owner: "go-gitea",
+ Name: "gitea",
+ CommitSha: "abcd1234abcd1234abcd1234abcd1234abcd1234",
+ RefLocation: &RefSpan{Start: 0, End: 55},
+ },
+ },
+ {
+ Input: "go-gitea/gitea@abcd1234abcd1234abcd1234abcd1234abcd12341234512345123451234512345", // longer than 64 characters
+ Expected: nil,
+ },
+ {
+ Input: "test go-gitea/gitea@abcd1234 test",
+ Expected: &RenderizableReference{
+ Owner: "go-gitea",
+ Name: "gitea",
+ CommitSha: "abcd1234",
+ RefLocation: &RefSpan{Start: 5, End: 28},
+ },
+ },
+ }
+
+ for _, c := range cases {
+ found, ref := FindRenderizableCommitCrossReference(c.Input)
+ assert.Equal(t, ref != nil, found)
+ assert.Equal(t, c.Expected, ref)
+ }
+}
+
+func TestRegExp_mentionPattern(t *testing.T) {
+ trueTestCases := []struct {
+ pat string
+ exp string
+ }{
+ {"@User", "@User"},
+ {"@ANT_123", "@ANT_123"},
+ {"@xxx-DiN0-z-A..uru..s-xxx", "@xxx-DiN0-z-A..uru..s-xxx"},
+ {" @lol ", "@lol"},
+ {" @Te-st", "@Te-st"},
+ {"(@gitea)", "@gitea"},
+ {"[@gitea]", "@gitea"},
+ {"@gitea! this", "@gitea"},
+ {"@gitea? this", "@gitea"},
+ {"@gitea. this", "@gitea"},
+ {"@gitea, this", "@gitea"},
+ {"@gitea; this", "@gitea"},
+ {"@gitea!\nthis", "@gitea"},
+ {"\n@gitea?\nthis", "@gitea"},
+ {"\t@gitea.\nthis", "@gitea"},
+ {"@gitea,\nthis", "@gitea"},
+ {"@gitea;\nthis", "@gitea"},
+ {"@gitea!", "@gitea"},
+ {"@gitea?", "@gitea"},
+ {"@gitea.", "@gitea"},
+ {"@gitea,", "@gitea"},
+ {"@gitea;", "@gitea"},
+ {"@gitea/team1;", "@gitea/team1"},
+ {"@jess'", "@jess"},
+ {"@forgejo's", "@forgejo"},
+ {"Оно ÑломалоÑÑŒ из-за коммитов от @jopik'а", "@jopik"},
+ }
+ falseTestCases := []string{
+ "@ 0",
+ "@ ",
+ "@",
+ "",
+ "ABC",
+ "@.ABC",
+ "/home/gitea/@gitea",
+ "\"@gitea\"",
+ "@@gitea",
+ "@gitea!this",
+ "@gitea?this",
+ "@gitea,this",
+ "@gitea;this",
+ "@gitea/team1/more",
+ }
+
+ for _, testCase := range trueTestCases {
+ found := mentionPattern.FindStringSubmatch(testCase.pat)
+ assert.Len(t, found, 2)
+ assert.Equal(t, testCase.exp, found[1])
+ }
+ for _, testCase := range falseTestCases {
+ res := mentionPattern.MatchString(testCase)
+ assert.False(t, res, "[%s] should be false", testCase)
+ }
+}
+
+func TestRegExp_issueNumericPattern(t *testing.T) {
+ trueTestCases := []string{
+ "#1234",
+ "#0",
+ "#1234567890987654321",
+ " #12",
+ "#12:",
+ "ref: #12: msg",
+ "\"#1234\"",
+ "'#1234'",
+ }
+ falseTestCases := []string{
+ "# 1234",
+ "# 0",
+ "# ",
+ "#",
+ "#ABC",
+ "#1A2B",
+ "",
+ "ABC",
+ }
+
+ for _, testCase := range trueTestCases {
+ assert.True(t, issueNumericPattern.MatchString(testCase))
+ }
+ for _, testCase := range falseTestCases {
+ assert.False(t, issueNumericPattern.MatchString(testCase))
+ }
+}
+
+func TestRegExp_issueAlphanumericPattern(t *testing.T) {
+ trueTestCases := []string{
+ "ABC-1234",
+ "A-1",
+ "RC-80",
+ "ABCDEFGHIJ-1234567890987654321234567890",
+ "ABC-123.",
+ "(ABC-123)",
+ "[ABC-123]",
+ "ABC-123:",
+ "\"ABC-123\"",
+ "'ABC-123'",
+ }
+ falseTestCases := []string{
+ "RC-08",
+ "PR-0",
+ "ABCDEFGHIJK-1",
+ "PR_1",
+ "",
+ "#ABC",
+ "",
+ "ABC",
+ "GG-",
+ "rm-1",
+ "/home/gitea/ABC-1234",
+ "MY-STRING-ABC-123",
+ }
+
+ for _, testCase := range trueTestCases {
+ assert.True(t, issueAlphanumericPattern.MatchString(testCase))
+ }
+ for _, testCase := range falseTestCases {
+ assert.False(t, issueAlphanumericPattern.MatchString(testCase))
+ }
+}
+
+func TestCustomizeCloseKeywords(t *testing.T) {
+ fixtures := []testFixture{
+ {
+ "Simplemente cierra: #29 yes",
+ []testResult{
+ {29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 20, End: 23}, &RefSpan{Start: 12, End: 18}, ""},
+ },
+ },
+ {
+ "Closes: #123 no, this English.",
+ []testResult{
+ {123, "", "", "123", false, XRefActionNone, &RefSpan{Start: 8, End: 12}, nil, ""},
+ },
+ },
+ {
+ "Cerró org6/repo6#300 yes",
+ []testResult{
+ {300, "org6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 7, End: 21}, &RefSpan{Start: 0, End: 6}, ""},
+ },
+ },
+ {
+ "Reabre org3/repo4#200 yes",
+ []testResult{
+ {200, "org3", "repo4", "200", false, XRefActionReopens, &RefSpan{Start: 7, End: 21}, &RefSpan{Start: 0, End: 6}, ""},
+ },
+ },
+ }
+
+ issueKeywordsOnce.Do(func() {})
+
+ doNewKeywords([]string{"cierra", "cerró"}, []string{"reabre"})
+ testFixtures(t, fixtures, "spanish")
+
+ // Restore default settings
+ doNewKeywords(setting.Repository.PullRequest.CloseKeywords, setting.Repository.PullRequest.ReopenKeywords)
+}
+
+func TestParseCloseKeywords(t *testing.T) {
+ // Test parsing of CloseKeywords and ReopenKeywords
+ assert.Empty(t, parseKeywords([]string{""}))
+ assert.Len(t, parseKeywords([]string{" aa ", " bb ", "99", "#", "", "this is", "cc"}), 3)
+
+ for _, test := range []struct {
+ pattern string
+ match string
+ expected string
+ }{
+ {"close", "This PR will close ", "close"},
+ {"cerró", "cerró ", "cerró"},
+ {"cerró", "AQUà SE CERRÓ: ", "CERRÓ"},
+ {"закрываетÑÑ", "закрываетÑÑ ", "закрываетÑÑ"},
+ {"κλείνει", "κλείνει: ", "κλείνει"},
+ {"关闭", "关闭 ", "关闭"},
+ {"é–‰ã˜ã¾ã™", "é–‰ã˜ã¾ã™ ", "é–‰ã˜ã¾ã™"},
+ {",$!", "", ""},
+ {"1234", "", ""},
+ } {
+ // The pattern only needs to match the part that precedes the reference.
+ // getCrossReference() takes care of finding the reference itself.
+ pat := makeKeywordsPat([]string{test.pattern})
+ if test.expected == "" {
+ assert.Nil(t, pat)
+ } else {
+ assert.NotNil(t, pat)
+ res := pat.FindAllStringSubmatch(test.match, -1)
+ assert.Len(t, res, 1)
+ assert.Len(t, res[0], 2)
+ assert.EqualValues(t, test.expected, res[0][1])
+ }
+ }
+}
diff --git a/modules/regexplru/regexplru.go b/modules/regexplru/regexplru.go
new file mode 100644
index 0000000..8f66dcf
--- /dev/null
+++ b/modules/regexplru/regexplru.go
@@ -0,0 +1,44 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package regexplru
+
+import (
+ "regexp"
+
+ "code.gitea.io/gitea/modules/log"
+
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+var lruCache *lru.Cache[string, any]
+
+func init() {
+ var err error
+ lruCache, err = lru.New[string, any](1000)
+ if err != nil {
+ log.Fatal("failed to new LRU cache, err: %v", err)
+ }
+}
+
+// GetCompiled works like regexp.Compile, the compiled expr or error is stored in LRU cache
+func GetCompiled(expr string) (r *regexp.Regexp, err error) {
+ v, ok := lruCache.Get(expr)
+ if !ok {
+ r, err = regexp.Compile(expr)
+ if err != nil {
+ lruCache.Add(expr, err)
+ return nil, err
+ }
+ lruCache.Add(expr, r)
+ } else {
+ r, ok = v.(*regexp.Regexp)
+ if !ok {
+ if err, ok = v.(error); ok {
+ return nil, err
+ }
+ panic("impossible")
+ }
+ }
+ return r, nil
+}
diff --git a/modules/regexplru/regexplru_test.go b/modules/regexplru/regexplru_test.go
new file mode 100644
index 0000000..8c0c722
--- /dev/null
+++ b/modules/regexplru/regexplru_test.go
@@ -0,0 +1,27 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package regexplru
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegexpLru(t *testing.T) {
+ r, err := GetCompiled("a")
+ require.NoError(t, err)
+ assert.True(t, r.MatchString("a"))
+
+ r, err = GetCompiled("a")
+ require.NoError(t, err)
+ assert.True(t, r.MatchString("a"))
+
+ assert.EqualValues(t, 1, lruCache.Len())
+
+ _, err = GetCompiled("(")
+ require.Error(t, err)
+ assert.EqualValues(t, 2, lruCache.Len())
+}
diff --git a/modules/repository/branch.go b/modules/repository/branch.go
new file mode 100644
index 0000000..2bf9930
--- /dev/null
+++ b/modules/repository/branch.go
@@ -0,0 +1,145 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// SyncRepoBranches synchronizes branch table with repository branches
+func SyncRepoBranches(ctx context.Context, repoID, doerID int64) (int64, error) {
+ repo, err := repo_model.GetRepositoryByID(ctx, repoID)
+ if err != nil {
+ return 0, err
+ }
+
+ log.Debug("SyncRepoBranches: in Repo[%d:%s]", repo.ID, repo.FullName())
+
+ gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ log.Error("OpenRepository[%s]: %w", repo.FullName(), err)
+ return 0, err
+ }
+ defer gitRepo.Close()
+
+ return SyncRepoBranchesWithRepo(ctx, repo, gitRepo, doerID)
+}
+
+func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doerID int64) (int64, error) {
+ objFmt, err := gitRepo.GetObjectFormat()
+ if err != nil {
+ return 0, fmt.Errorf("GetObjectFormat: %w", err)
+ }
+ _, err = db.GetEngine(ctx).ID(repo.ID).Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()})
+ if err != nil {
+ return 0, fmt.Errorf("UpdateRepository: %w", err)
+ }
+ repo.ObjectFormatName = objFmt.Name() // keep consistent with db
+
+ allBranches := container.Set[string]{}
+ {
+ branches, _, err := gitRepo.GetBranchNames(0, 0)
+ if err != nil {
+ return 0, err
+ }
+ log.Trace("SyncRepoBranches[%s]: branches[%d]: %v", repo.FullName(), len(branches), branches)
+ for _, branch := range branches {
+ allBranches.Add(branch)
+ }
+ }
+
+ dbBranches := make(map[string]*git_model.Branch)
+ {
+ branches, err := db.Find[git_model.Branch](ctx, git_model.FindBranchOptions{
+ ListOptions: db.ListOptionsAll,
+ RepoID: repo.ID,
+ })
+ if err != nil {
+ return 0, err
+ }
+ for _, branch := range branches {
+ dbBranches[branch.Name] = branch
+ }
+ }
+
+ var toAdd []*git_model.Branch
+ var toUpdate []*git_model.Branch
+ var toRemove []int64
+ for branch := range allBranches {
+ dbb := dbBranches[branch]
+ commit, err := gitRepo.GetBranchCommit(branch)
+ if err != nil {
+ return 0, err
+ }
+ if dbb == nil {
+ toAdd = append(toAdd, &git_model.Branch{
+ RepoID: repo.ID,
+ Name: branch,
+ CommitID: commit.ID.String(),
+ CommitMessage: commit.Summary(),
+ PusherID: doerID,
+ CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
+ })
+ } else if commit.ID.String() != dbb.CommitID {
+ toUpdate = append(toUpdate, &git_model.Branch{
+ ID: dbb.ID,
+ RepoID: repo.ID,
+ Name: branch,
+ CommitID: commit.ID.String(),
+ CommitMessage: commit.Summary(),
+ PusherID: doerID,
+ CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
+ })
+ }
+ }
+
+ for _, dbBranch := range dbBranches {
+ if !allBranches.Contains(dbBranch.Name) && !dbBranch.IsDeleted {
+ toRemove = append(toRemove, dbBranch.ID)
+ }
+ }
+
+ log.Trace("SyncRepoBranches[%s]: toAdd: %v, toUpdate: %v, toRemove: %v", repo.FullName(), toAdd, toUpdate, toRemove)
+
+ if len(toAdd) == 0 && len(toRemove) == 0 && len(toUpdate) == 0 {
+ return int64(len(allBranches)), nil
+ }
+
+ if err := db.WithTx(ctx, func(ctx context.Context) error {
+ if len(toAdd) > 0 {
+ if err := git_model.AddBranches(ctx, toAdd); err != nil {
+ return err
+ }
+ }
+
+ for _, b := range toUpdate {
+ if _, err := db.GetEngine(ctx).ID(b.ID).
+ Cols("commit_id, commit_message, pusher_id, commit_time, is_deleted").
+ Update(b); err != nil {
+ return err
+ }
+ }
+
+ if len(toRemove) > 0 {
+ if err := git_model.DeleteBranches(ctx, repo.ID, doerID, toRemove); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }); err != nil {
+ return 0, err
+ }
+ return int64(len(allBranches)), nil
+}
diff --git a/modules/repository/branch_test.go b/modules/repository/branch_test.go
new file mode 100644
index 0000000..b98618a
--- /dev/null
+++ b/modules/repository/branch_test.go
@@ -0,0 +1,32 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSyncRepoBranches(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ _, err := db.GetEngine(db.DefaultContext).ID(1).Update(&repo_model.Repository{ObjectFormatName: "bad-fmt"})
+ require.NoError(t, db.TruncateBeans(db.DefaultContext, &git_model.Branch{}))
+ require.NoError(t, err)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "bad-fmt", repo.ObjectFormatName)
+ _, err = SyncRepoBranches(db.DefaultContext, 1, 0)
+ require.NoError(t, err)
+ repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "sha1", repo.ObjectFormatName)
+ branch, err := git_model.GetBranch(db.DefaultContext, 1, "master")
+ require.NoError(t, err)
+ assert.EqualValues(t, "master", branch.Name)
+}
diff --git a/modules/repository/collaborator.go b/modules/repository/collaborator.go
new file mode 100644
index 0000000..17915d3
--- /dev/null
+++ b/modules/repository/collaborator.go
@@ -0,0 +1,44 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "xorm.io/builder"
+)
+
+func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error {
+ if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) {
+ return user_model.ErrBlockedByUser
+ }
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ has, err := db.Exist[repo_model.Collaboration](ctx, builder.Eq{
+ "repo_id": repo.ID,
+ "user_id": u.ID,
+ })
+ if err != nil {
+ return err
+ } else if has {
+ return nil
+ }
+
+ if err = db.Insert(ctx, &repo_model.Collaboration{
+ RepoID: repo.ID,
+ UserID: u.ID,
+ Mode: perm.AccessModeWrite,
+ }); err != nil {
+ return err
+ }
+
+ return access_model.RecalculateUserAccess(ctx, repo, u.ID)
+ })
+}
diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go
new file mode 100644
index 0000000..3844197
--- /dev/null
+++ b/modules/repository/collaborator_test.go
@@ -0,0 +1,308 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ perm_model "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_AddCollaborator(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ testSuccess := func(repoID, userID int64) {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+ require.NoError(t, repo.LoadOwner(db.DefaultContext))
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+ require.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
+ unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+ }
+ testSuccess(1, 4)
+ testSuccess(1, 4)
+ testSuccess(3, 4)
+}
+
+func TestRepository_AddCollaborator_IsBlocked(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ testSuccess := func(repoID, userID int64) {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+ require.NoError(t, repo.LoadOwner(db.DefaultContext))
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+
+ // Owner blocked user.
+ unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+ require.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+ unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+ _, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+ require.NoError(t, err)
+
+ // User has owner blocked.
+ unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID})
+ require.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+ unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+ }
+ // Ensure idempotency (public repository).
+ testSuccess(1, 4)
+ testSuccess(1, 4)
+ // Add collaborator to private repository.
+ testSuccess(3, 4)
+}
+
+func TestRepoPermissionPublicNonOrgRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // public non-organization repo
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ require.NoError(t, repo.LoadUnits(db.DefaultContext))
+
+ // plain user
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // change to collaborator
+ require.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // collaborator
+ collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, collaborator)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // owner
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // admin
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+}
+
+func TestRepoPermissionPrivateNonOrgRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // private non-organization repo
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ require.NoError(t, repo.LoadUnits(db.DefaultContext))
+
+ // plain user
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.False(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // change to collaborator to default write access
+ require.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ require.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // owner
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // admin
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+}
+
+func TestRepoPermissionPublicOrgRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // public organization repo
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
+ require.NoError(t, repo.LoadUnits(db.DefaultContext))
+
+ // plain user
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // change to collaborator to default write access
+ require.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ require.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // org member team owner
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // org member team tester
+ member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, member)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ }
+ assert.True(t, perm.CanWrite(unit.TypeIssues))
+ assert.False(t, perm.CanWrite(unit.TypeCode))
+
+ // admin
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+}
+
+func TestRepoPermissionPrivateOrgRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // private organization repo
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 24})
+ require.NoError(t, repo.LoadUnits(db.DefaultContext))
+
+ // plain user
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.False(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // change to collaborator to default write access
+ require.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ require.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.False(t, perm.CanWrite(unit.Type))
+ }
+
+ // org member team owner
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // update team information and then check permission
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5})
+ err = organization.UpdateTeamUnits(db.DefaultContext, team, nil)
+ require.NoError(t, err)
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+
+ // org member team tester
+ tester := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, tester)
+ require.NoError(t, err)
+ assert.True(t, perm.CanWrite(unit.TypeIssues))
+ assert.False(t, perm.CanWrite(unit.TypeCode))
+ assert.False(t, perm.CanRead(unit.TypeCode))
+
+ // org member team reviewer
+ reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, reviewer)
+ require.NoError(t, err)
+ assert.False(t, perm.CanRead(unit.TypeIssues))
+ assert.False(t, perm.CanWrite(unit.TypeCode))
+ assert.True(t, perm.CanRead(unit.TypeCode))
+
+ // admin
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
+ require.NoError(t, err)
+ for _, unit := range repo.Units {
+ assert.True(t, perm.CanRead(unit.Type))
+ assert.True(t, perm.CanWrite(unit.Type))
+ }
+}
diff --git a/modules/repository/commits.go b/modules/repository/commits.go
new file mode 100644
index 0000000..ede6042
--- /dev/null
+++ b/modules/repository/commits.go
@@ -0,0 +1,173 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "time"
+
+ "code.gitea.io/gitea/models/avatars"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+)
+
+// PushCommit represents a commit in a push operation.
+type PushCommit struct {
+ Sha1 string
+ Message string
+ AuthorEmail string
+ AuthorName string
+ CommitterEmail string
+ CommitterName string
+ Timestamp time.Time
+}
+
+// PushCommits represents list of commits in a push operation.
+type PushCommits struct {
+ Commits []*PushCommit
+ HeadCommit *PushCommit
+ CompareURL string
+ Len int
+}
+
+// NewPushCommits creates a new PushCommits object.
+func NewPushCommits() *PushCommits {
+ return &PushCommits{}
+}
+
+// toAPIPayloadCommit converts a single PushCommit to an api.PayloadCommit object.
+func (pc *PushCommits) toAPIPayloadCommit(ctx context.Context, emailUsers map[string]*user_model.User, repoPath, repoLink string, commit *PushCommit) (*api.PayloadCommit, error) {
+ var err error
+ authorUsername := ""
+ author, ok := emailUsers[commit.AuthorEmail]
+ if !ok {
+ author, err = user_model.GetUserByEmail(ctx, commit.AuthorEmail)
+ if err == nil {
+ authorUsername = author.Name
+ emailUsers[commit.AuthorEmail] = author
+ }
+ } else {
+ authorUsername = author.Name
+ }
+
+ committerUsername := ""
+ committer, ok := emailUsers[commit.CommitterEmail]
+ if !ok {
+ committer, err = user_model.GetUserByEmail(ctx, commit.CommitterEmail)
+ if err == nil {
+ // TODO: check errors other than email not found.
+ committerUsername = committer.Name
+ emailUsers[commit.CommitterEmail] = committer
+ }
+ } else {
+ committerUsername = committer.Name
+ }
+
+ fileStatus, err := git.GetCommitFileStatus(ctx, repoPath, commit.Sha1)
+ if err != nil {
+ return nil, fmt.Errorf("FileStatus [commit_sha1: %s]: %w", commit.Sha1, err)
+ }
+
+ return &api.PayloadCommit{
+ ID: commit.Sha1,
+ Message: commit.Message,
+ URL: fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(commit.Sha1)),
+ Author: &api.PayloadUser{
+ Name: commit.AuthorName,
+ Email: commit.AuthorEmail,
+ UserName: authorUsername,
+ },
+ Committer: &api.PayloadUser{
+ Name: commit.CommitterName,
+ Email: commit.CommitterEmail,
+ UserName: committerUsername,
+ },
+ Added: fileStatus.Added,
+ Removed: fileStatus.Removed,
+ Modified: fileStatus.Modified,
+ Timestamp: commit.Timestamp,
+ }, nil
+}
+
+// ToAPIPayloadCommits converts a PushCommits object to api.PayloadCommit format.
+// It returns all converted commits and, if provided, the head commit or an error otherwise.
+func (pc *PushCommits) ToAPIPayloadCommits(ctx context.Context, repoPath, repoLink string) ([]*api.PayloadCommit, *api.PayloadCommit, error) {
+ commits := make([]*api.PayloadCommit, len(pc.Commits))
+ var headCommit *api.PayloadCommit
+
+ emailUsers := make(map[string]*user_model.User)
+
+ for i, commit := range pc.Commits {
+ apiCommit, err := pc.toAPIPayloadCommit(ctx, emailUsers, repoPath, repoLink, commit)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ commits[i] = apiCommit
+ if pc.HeadCommit != nil && pc.HeadCommit.Sha1 == commits[i].ID {
+ headCommit = apiCommit
+ }
+ }
+ if pc.HeadCommit != nil && headCommit == nil {
+ var err error
+ headCommit, err = pc.toAPIPayloadCommit(ctx, emailUsers, repoPath, repoLink, pc.HeadCommit)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ return commits, headCommit, nil
+}
+
+// AvatarLink tries to match user in database with e-mail
+// in order to show custom avatar, and falls back to general avatar link.
+func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string {
+ size := avatars.DefaultAvatarPixelSize * setting.Avatar.RenderedSizeFactor
+
+ v, _ := cache.GetWithContextCache(ctx, "push_commits", email, func() (string, error) {
+ u, err := user_model.GetUserByEmail(ctx, email)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByEmail: %v", err)
+ return "", err
+ }
+ return avatars.GenerateEmailAvatarFastLink(ctx, email, size), nil
+ }
+ return u.AvatarLinkWithSize(ctx, size), nil
+ })
+
+ return v
+}
+
+// CommitToPushCommit transforms a git.Commit to PushCommit type.
+func CommitToPushCommit(commit *git.Commit) *PushCommit {
+ return &PushCommit{
+ Sha1: commit.ID.String(),
+ Message: commit.Message(),
+ AuthorEmail: commit.Author.Email,
+ AuthorName: commit.Author.Name,
+ CommitterEmail: commit.Committer.Email,
+ CommitterName: commit.Committer.Name,
+ Timestamp: commit.Author.When,
+ }
+}
+
+// GitToPushCommits transforms a list of git.Commits to PushCommits type.
+func GitToPushCommits(gitCommits []*git.Commit) *PushCommits {
+ commits := make([]*PushCommit, 0, len(gitCommits))
+ for _, commit := range gitCommits {
+ commits = append(commits, CommitToPushCommit(commit))
+ }
+ return &PushCommits{
+ Commits: commits,
+ HeadCommit: nil,
+ CompareURL: "",
+ Len: len(commits),
+ }
+}
diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go
new file mode 100644
index 0000000..82841b3
--- /dev/null
+++ b/modules/repository/commits_test.go
@@ -0,0 +1,210 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "crypto/md5"
+ "fmt"
+ "strconv"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPushCommits_ToAPIPayloadCommits(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ pushCommits := NewPushCommits()
+ pushCommits.Commits = []*PushCommit{
+ {
+ Sha1: "69554a6",
+ CommitterEmail: "user2@example.com",
+ CommitterName: "User2",
+ AuthorEmail: "user2@example.com",
+ AuthorName: "User2",
+ Message: "not signed commit",
+ },
+ {
+ Sha1: "27566bd",
+ CommitterEmail: "user2@example.com",
+ CommitterName: "User2",
+ AuthorEmail: "user2@example.com",
+ AuthorName: "User2",
+ Message: "good signed commit (with not yet validated email)",
+ },
+ {
+ Sha1: "5099b81",
+ CommitterEmail: "user2@example.com",
+ CommitterName: "User2",
+ AuthorEmail: "user2@example.com",
+ AuthorName: "User2",
+ Message: "good signed commit",
+ },
+ }
+ pushCommits.HeadCommit = &PushCommit{Sha1: "69554a6"}
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16})
+ payloadCommits, headCommit, err := pushCommits.ToAPIPayloadCommits(git.DefaultContext, repo.RepoPath(), "/user2/repo16")
+ require.NoError(t, err)
+ assert.Len(t, payloadCommits, 3)
+ assert.NotNil(t, headCommit)
+
+ assert.Equal(t, "69554a6", payloadCommits[0].ID)
+ assert.Equal(t, "not signed commit", payloadCommits[0].Message)
+ assert.Equal(t, "/user2/repo16/commit/69554a6", payloadCommits[0].URL)
+ assert.Equal(t, "User2", payloadCommits[0].Committer.Name)
+ assert.Equal(t, "user2", payloadCommits[0].Committer.UserName)
+ assert.Equal(t, "User2", payloadCommits[0].Author.Name)
+ assert.Equal(t, "user2", payloadCommits[0].Author.UserName)
+ assert.EqualValues(t, []string{}, payloadCommits[0].Added)
+ assert.EqualValues(t, []string{}, payloadCommits[0].Removed)
+ assert.EqualValues(t, []string{"readme.md"}, payloadCommits[0].Modified)
+
+ assert.Equal(t, "27566bd", payloadCommits[1].ID)
+ assert.Equal(t, "good signed commit (with not yet validated email)", payloadCommits[1].Message)
+ assert.Equal(t, "/user2/repo16/commit/27566bd", payloadCommits[1].URL)
+ assert.Equal(t, "User2", payloadCommits[1].Committer.Name)
+ assert.Equal(t, "user2", payloadCommits[1].Committer.UserName)
+ assert.Equal(t, "User2", payloadCommits[1].Author.Name)
+ assert.Equal(t, "user2", payloadCommits[1].Author.UserName)
+ assert.EqualValues(t, []string{}, payloadCommits[1].Added)
+ assert.EqualValues(t, []string{}, payloadCommits[1].Removed)
+ assert.EqualValues(t, []string{"readme.md"}, payloadCommits[1].Modified)
+
+ assert.Equal(t, "5099b81", payloadCommits[2].ID)
+ assert.Equal(t, "good signed commit", payloadCommits[2].Message)
+ assert.Equal(t, "/user2/repo16/commit/5099b81", payloadCommits[2].URL)
+ assert.Equal(t, "User2", payloadCommits[2].Committer.Name)
+ assert.Equal(t, "user2", payloadCommits[2].Committer.UserName)
+ assert.Equal(t, "User2", payloadCommits[2].Author.Name)
+ assert.Equal(t, "user2", payloadCommits[2].Author.UserName)
+ assert.EqualValues(t, []string{"readme.md"}, payloadCommits[2].Added)
+ assert.EqualValues(t, []string{}, payloadCommits[2].Removed)
+ assert.EqualValues(t, []string{}, payloadCommits[2].Modified)
+
+ assert.Equal(t, "69554a6", headCommit.ID)
+ assert.Equal(t, "not signed commit", headCommit.Message)
+ assert.Equal(t, "/user2/repo16/commit/69554a6", headCommit.URL)
+ assert.Equal(t, "User2", headCommit.Committer.Name)
+ assert.Equal(t, "user2", headCommit.Committer.UserName)
+ assert.Equal(t, "User2", headCommit.Author.Name)
+ assert.Equal(t, "user2", headCommit.Author.UserName)
+ assert.EqualValues(t, []string{}, headCommit.Added)
+ assert.EqualValues(t, []string{}, headCommit.Removed)
+ assert.EqualValues(t, []string{"readme.md"}, headCommit.Modified)
+}
+
+func TestPushCommits_AvatarLink(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ pushCommits := NewPushCommits()
+ pushCommits.Commits = []*PushCommit{
+ {
+ Sha1: "abcdef1",
+ CommitterEmail: "user2@example.com",
+ CommitterName: "User Two",
+ AuthorEmail: "user4@example.com",
+ AuthorName: "User Four",
+ Message: "message1",
+ },
+ {
+ Sha1: "abcdef2",
+ CommitterEmail: "user2@example.com",
+ CommitterName: "User Two",
+ AuthorEmail: "user2@example.com",
+ AuthorName: "User Two",
+ Message: "message2",
+ },
+ }
+
+ setting.GravatarSource = "https://secure.gravatar.com/avatar"
+ setting.OfflineMode = true
+
+ assert.Equal(t,
+ "/avatars/avatar2?size="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor),
+ pushCommits.AvatarLink(db.DefaultContext, "user2@example.com"))
+
+ assert.Equal(t,
+ fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("nonexistent@example.com")), 28*setting.Avatar.RenderedSizeFactor),
+ pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com"))
+}
+
+func TestCommitToPushCommit(t *testing.T) {
+ now := time.Now()
+ sig := &git.Signature{
+ Email: "example@example.com",
+ Name: "John Doe",
+ When: now,
+ }
+ const hexString = "0123456789abcdef0123456789abcdef01234567"
+ sha1, err := git.NewIDFromString(hexString)
+ require.NoError(t, err)
+ pushCommit := CommitToPushCommit(&git.Commit{
+ ID: sha1,
+ Author: sig,
+ Committer: sig,
+ CommitMessage: "Commit Message",
+ })
+ assert.Equal(t, hexString, pushCommit.Sha1)
+ assert.Equal(t, "Commit Message", pushCommit.Message)
+ assert.Equal(t, "example@example.com", pushCommit.AuthorEmail)
+ assert.Equal(t, "John Doe", pushCommit.AuthorName)
+ assert.Equal(t, "example@example.com", pushCommit.CommitterEmail)
+ assert.Equal(t, "John Doe", pushCommit.CommitterName)
+ assert.Equal(t, now, pushCommit.Timestamp)
+}
+
+func TestListToPushCommits(t *testing.T) {
+ now := time.Now()
+ sig := &git.Signature{
+ Email: "example@example.com",
+ Name: "John Doe",
+ When: now,
+ }
+
+ const hexString1 = "0123456789abcdef0123456789abcdef01234567"
+ hash1, err := git.NewIDFromString(hexString1)
+ require.NoError(t, err)
+ const hexString2 = "fedcba9876543210fedcba9876543210fedcba98"
+ hash2, err := git.NewIDFromString(hexString2)
+ require.NoError(t, err)
+
+ l := []*git.Commit{
+ {
+ ID: hash1,
+ Author: sig,
+ Committer: sig,
+ CommitMessage: "Message1",
+ },
+ {
+ ID: hash2,
+ Author: sig,
+ Committer: sig,
+ CommitMessage: "Message2",
+ },
+ }
+
+ pushCommits := GitToPushCommits(l)
+ if assert.Len(t, pushCommits.Commits, 2) {
+ assert.Equal(t, "Message1", pushCommits.Commits[0].Message)
+ assert.Equal(t, hexString1, pushCommits.Commits[0].Sha1)
+ assert.Equal(t, "example@example.com", pushCommits.Commits[0].AuthorEmail)
+ assert.Equal(t, now, pushCommits.Commits[0].Timestamp)
+
+ assert.Equal(t, "Message2", pushCommits.Commits[1].Message)
+ assert.Equal(t, hexString2, pushCommits.Commits[1].Sha1)
+ assert.Equal(t, "example@example.com", pushCommits.Commits[1].AuthorEmail)
+ assert.Equal(t, now, pushCommits.Commits[1].Timestamp)
+ }
+}
+
+// TODO TestPushUpdate
diff --git a/modules/repository/create.go b/modules/repository/create.go
new file mode 100644
index 0000000..ca2150b
--- /dev/null
+++ b/modules/repository/create.go
@@ -0,0 +1,297 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/models/webhook"
+ issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// CreateRepositoryByExample creates a repository for the user/organization.
+func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository, overwriteOrAdopt, isFork bool) (err error) {
+ if err = repo_model.IsUsableRepoName(repo.Name); err != nil {
+ return err
+ }
+
+ has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name)
+ if err != nil {
+ return fmt.Errorf("IsRepositoryExist: %w", err)
+ } else if has {
+ return repo_model.ErrRepoAlreadyExist{
+ Uname: u.Name,
+ Name: repo.Name,
+ }
+ }
+
+ repoPath := repo_model.RepoPath(u.Name, repo.Name)
+ isExist, err := util.IsExist(repoPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
+ return err
+ }
+ if !overwriteOrAdopt && isExist {
+ log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath)
+ return repo_model.ErrRepoFilesAlreadyExist{
+ Uname: u.Name,
+ Name: repo.Name,
+ }
+ }
+
+ if err = db.Insert(ctx, repo); err != nil {
+ return err
+ }
+ if err = repo_model.DeleteRedirect(ctx, u.ID, repo.Name); err != nil {
+ return err
+ }
+
+ // insert units for repo
+ defaultUnits := unit.DefaultRepoUnits
+ if isFork {
+ defaultUnits = unit.DefaultForkRepoUnits
+ }
+ units := make([]repo_model.RepoUnit, 0, len(defaultUnits))
+ for _, tp := range defaultUnits {
+ if tp == unit.TypeIssues {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: tp,
+ Config: &repo_model.IssuesConfig{
+ EnableTimetracker: setting.Service.DefaultEnableTimetracking,
+ AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime,
+ EnableDependencies: setting.Service.DefaultEnableDependencies,
+ },
+ })
+ } else if tp == unit.TypePullRequests {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: tp,
+ Config: &repo_model.PullRequestsConfig{
+ AllowMerge: true, AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, AllowFastForwardOnly: true,
+ DefaultMergeStyle: repo_model.MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle),
+ AllowRebaseUpdate: true,
+ },
+ })
+ } else {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: tp,
+ })
+ }
+ }
+
+ if err = db.Insert(ctx, units); err != nil {
+ return err
+ }
+
+ // Remember visibility preference.
+ u.LastRepoVisibility = repo.IsPrivate
+ if err = user_model.UpdateUserCols(ctx, u, "last_repo_visibility"); err != nil {
+ return fmt.Errorf("UpdateUserCols: %w", err)
+ }
+
+ if err = user_model.IncrUserRepoNum(ctx, u.ID); err != nil {
+ return fmt.Errorf("IncrUserRepoNum: %w", err)
+ }
+ u.NumRepos++
+
+ // Give access to all members in teams with access to all repositories.
+ if u.IsOrganization() {
+ teams, err := organization.FindOrgTeams(ctx, u.ID)
+ if err != nil {
+ return fmt.Errorf("FindOrgTeams: %w", err)
+ }
+ for _, t := range teams {
+ if t.IncludesAllRepositories {
+ if err := models.AddRepository(ctx, t, repo); err != nil {
+ return fmt.Errorf("AddRepository: %w", err)
+ }
+ }
+ }
+
+ if isAdmin, err := access_model.IsUserRepoAdmin(ctx, repo, doer); err != nil {
+ return fmt.Errorf("IsUserRepoAdmin: %w", err)
+ } else if !isAdmin {
+ // Make creator repo admin if it wasn't assigned automatically
+ if err = AddCollaborator(ctx, repo, doer); err != nil {
+ return fmt.Errorf("AddCollaborator: %w", err)
+ }
+ if err = repo_model.ChangeCollaborationAccessMode(ctx, repo, doer.ID, perm.AccessModeAdmin); err != nil {
+ return fmt.Errorf("ChangeCollaborationAccessModeCtx: %w", err)
+ }
+ }
+ } else if err = access_model.RecalculateAccesses(ctx, repo); err != nil {
+ // Organization automatically called this in AddRepository method.
+ return fmt.Errorf("RecalculateAccesses: %w", err)
+ }
+
+ if setting.Service.AutoWatchNewRepos {
+ if err = repo_model.WatchRepo(ctx, doer.ID, repo.ID, true); err != nil {
+ return fmt.Errorf("WatchRepo: %w", err)
+ }
+ }
+
+ if err = webhook.CopyDefaultWebhooksToRepo(ctx, repo.ID); err != nil {
+ return fmt.Errorf("CopyDefaultWebhooksToRepo: %w", err)
+ }
+
+ return nil
+}
+
+const notRegularFileMode = os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | os.ModeCharDevice | os.ModeIrregular
+
+// getDirectorySize returns the disk consumption for a given path
+func getDirectorySize(path string) (int64, error) {
+ var size int64
+ err := filepath.WalkDir(path, func(_ string, entry os.DirEntry, err error) error {
+ if os.IsNotExist(err) { // ignore the error because some files (like temp/lock file) may be deleted during traversing.
+ return nil
+ } else if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if os.IsNotExist(err) { // ignore the error as above
+ return nil
+ } else if err != nil {
+ return err
+ }
+ if (info.Mode() & notRegularFileMode) == 0 {
+ size += info.Size()
+ }
+ return nil
+ })
+ return size, err
+}
+
+// UpdateRepoSize updates the repository size, calculating it using getDirectorySize
+func UpdateRepoSize(ctx context.Context, repo *repo_model.Repository) error {
+ size, err := getDirectorySize(repo.RepoPath())
+ if err != nil {
+ return fmt.Errorf("updateSize: %w", err)
+ }
+
+ lfsSize, err := git_model.GetRepoLFSSize(ctx, repo.ID)
+ if err != nil {
+ return fmt.Errorf("updateSize: GetLFSMetaObjects: %w", err)
+ }
+
+ return repo_model.UpdateRepoSize(ctx, repo.ID, size, lfsSize)
+}
+
+// CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon...
+func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error {
+ if err := repo.LoadOwner(ctx); err != nil {
+ return err
+ }
+
+ // Create/Remove git-daemon-export-ok for git-daemon...
+ daemonExportFile := path.Join(repo.RepoPath(), `git-daemon-export-ok`)
+
+ isExist, err := util.IsExist(daemonExportFile)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", daemonExportFile, err)
+ return err
+ }
+
+ isPublic := !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePublic
+ if !isPublic && isExist {
+ if err = util.Remove(daemonExportFile); err != nil {
+ log.Error("Failed to remove %s: %v", daemonExportFile, err)
+ }
+ } else if isPublic && !isExist {
+ if f, err := os.Create(daemonExportFile); err != nil {
+ log.Error("Failed to create %s: %v", daemonExportFile, err)
+ } else {
+ f.Close()
+ }
+ }
+
+ return nil
+}
+
+// UpdateRepository updates a repository with db context
+func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibilityChanged bool) (err error) {
+ repo.LowerName = strings.ToLower(repo.Name)
+
+ e := db.GetEngine(ctx)
+
+ if _, err = e.ID(repo.ID).AllCols().Update(repo); err != nil {
+ return fmt.Errorf("update: %w", err)
+ }
+
+ if err = UpdateRepoSize(ctx, repo); err != nil {
+ log.Error("Failed to update size for repository: %v", err)
+ }
+
+ if visibilityChanged {
+ if err = repo.LoadOwner(ctx); err != nil {
+ return fmt.Errorf("LoadOwner: %w", err)
+ }
+ if repo.Owner.IsOrganization() {
+ // Organization repository need to recalculate access table when visibility is changed.
+ if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
+ return fmt.Errorf("recalculateTeamAccesses: %w", err)
+ }
+ }
+
+ // If repo has become private, we need to set its actions to private.
+ if repo.IsPrivate {
+ _, err = e.Where("repo_id = ?", repo.ID).Cols("is_private").Update(&activities_model.Action{
+ IsPrivate: true,
+ })
+ if err != nil {
+ return err
+ }
+
+ if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
+ return err
+ }
+ }
+
+ // Create/Remove git-daemon-export-ok for git-daemon...
+ if err := CheckDaemonExportOK(ctx, repo); err != nil {
+ return err
+ }
+
+ forkRepos, err := repo_model.GetRepositoriesByForkID(ctx, repo.ID)
+ if err != nil {
+ return fmt.Errorf("getRepositoriesByForkID: %w", err)
+ }
+ for i := range forkRepos {
+ forkRepos[i].IsPrivate = repo.IsPrivate || repo.Owner.Visibility == api.VisibleTypePrivate
+ if err = UpdateRepository(ctx, forkRepos[i], true); err != nil {
+ return fmt.Errorf("updateRepository[%d]: %w", forkRepos[i].ID, err)
+ }
+ }
+
+ // If visibility is changed, we need to update the issue indexer.
+ // Since the data in the issue indexer have field to indicate if the repo is public or not.
+ issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
+ }
+
+ return nil
+}
diff --git a/modules/repository/create_test.go b/modules/repository/create_test.go
new file mode 100644
index 0000000..c743271
--- /dev/null
+++ b/modules/repository/create_test.go
@@ -0,0 +1,46 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateRepositoryVisibilityChanged(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // Get sample repo and change visibility
+ repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 9)
+ require.NoError(t, err)
+ repo.IsPrivate = true
+
+ // Update it
+ err = UpdateRepository(db.DefaultContext, repo, true)
+ require.NoError(t, err)
+
+ // Check visibility of action has become private
+ act := activities_model.Action{}
+ _, err = db.GetEngine(db.DefaultContext).ID(3).Get(&act)
+
+ require.NoError(t, err)
+ assert.True(t, act.IsPrivate)
+}
+
+func TestGetDirectorySize(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 1)
+ require.NoError(t, err)
+
+ size, err := getDirectorySize(repo.RepoPath())
+ require.NoError(t, err)
+ assert.EqualValues(t, size, repo.Size)
+}
diff --git a/modules/repository/delete.go b/modules/repository/delete.go
new file mode 100644
index 0000000..04af98b
--- /dev/null
+++ b/modules/repository/delete.go
@@ -0,0 +1,33 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// CanUserDelete returns true if user could delete the repository
+func CanUserDelete(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
+ if user.IsAdmin || user.ID == repo.OwnerID {
+ return true, nil
+ }
+
+ if err := repo.LoadOwner(ctx); err != nil {
+ return false, err
+ }
+
+ if repo.Owner.IsOrganization() {
+ isAdmin, err := organization.OrgFromUser(repo.Owner).IsOrgAdmin(ctx, user.ID)
+ if err != nil {
+ return false, err
+ }
+ return isAdmin, nil
+ }
+
+ return false, nil
+}
diff --git a/modules/repository/env.go b/modules/repository/env.go
new file mode 100644
index 0000000..e4f3209
--- /dev/null
+++ b/modules/repository/env.go
@@ -0,0 +1,87 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// env keys for git hooks need
+const (
+ EnvRepoName = "GITEA_REPO_NAME"
+ EnvRepoUsername = "GITEA_REPO_USER_NAME"
+ EnvRepoID = "GITEA_REPO_ID"
+ EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
+ EnvPusherName = "GITEA_PUSHER_NAME"
+ EnvPusherEmail = "GITEA_PUSHER_EMAIL"
+ EnvPusherID = "GITEA_PUSHER_ID"
+ EnvKeyID = "GITEA_KEY_ID" // public key ID
+ EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
+ EnvPRID = "GITEA_PR_ID"
+ EnvPushTrigger = "GITEA_PUSH_TRIGGER"
+ EnvIsInternal = "GITEA_INTERNAL_PUSH"
+ EnvAppURL = "GITEA_ROOT_URL"
+ EnvActionPerm = "GITEA_ACTION_PERM"
+)
+
+type PushTrigger string
+
+const (
+ PushTriggerPRMergeToBase PushTrigger = "pr-merge-to-base"
+ PushTriggerPRUpdateWithBase PushTrigger = "pr-update-with-base"
+)
+
+// InternalPushingEnvironment returns an os environment to switch off hooks on push
+// It is recommended to avoid using this unless you are pushing within a transaction
+// or if you absolutely are sure that post-receive and pre-receive will do nothing
+// We provide the full pushing-environment for other hook providers
+func InternalPushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []string {
+ return append(PushingEnvironment(doer, repo),
+ EnvIsInternal+"=true",
+ )
+}
+
+// PushingEnvironment returns an os environment to allow hooks to work on push
+func PushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []string {
+ return FullPushingEnvironment(doer, doer, repo, repo.Name, 0)
+}
+
+// FullPushingEnvironment returns an os environment to allow hooks to work on push
+func FullPushingEnvironment(author, committer *user_model.User, repo *repo_model.Repository, repoName string, prID int64) []string {
+ isWiki := "false"
+ if strings.HasSuffix(repoName, ".wiki") {
+ isWiki = "true"
+ }
+
+ authorSig := author.NewGitSig()
+ committerSig := committer.NewGitSig()
+
+ environ := append(os.Environ(),
+ "GIT_AUTHOR_NAME="+authorSig.Name,
+ "GIT_AUTHOR_EMAIL="+authorSig.Email,
+ "GIT_COMMITTER_NAME="+committerSig.Name,
+ "GIT_COMMITTER_EMAIL="+committerSig.Email,
+ EnvRepoName+"="+repoName,
+ EnvRepoUsername+"="+repo.OwnerName,
+ EnvRepoIsWiki+"="+isWiki,
+ EnvPusherName+"="+committer.Name,
+ EnvPusherID+"="+fmt.Sprintf("%d", committer.ID),
+ EnvRepoID+"="+fmt.Sprintf("%d", repo.ID),
+ EnvPRID+"="+fmt.Sprintf("%d", prID),
+ EnvAppURL+"="+setting.AppURL,
+ "SSH_ORIGINAL_COMMAND=gitea-internal",
+ )
+
+ if !committer.KeepEmailPrivate {
+ environ = append(environ, EnvPusherEmail+"="+committer.Email)
+ }
+
+ return environ
+}
diff --git a/modules/repository/fork.go b/modules/repository/fork.go
new file mode 100644
index 0000000..fbf0008
--- /dev/null
+++ b/modules/repository/fork.go
@@ -0,0 +1,32 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// CanUserForkRepo returns true if specified user can fork repository.
+func CanUserForkRepo(ctx context.Context, user *user_model.User, repo *repo_model.Repository) (bool, error) {
+ if user == nil {
+ return false, nil
+ }
+ if repo.OwnerID != user.ID && !repo_model.HasForkedRepo(ctx, user.ID, repo.ID) {
+ return true, nil
+ }
+ ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, user.ID)
+ if err != nil {
+ return false, err
+ }
+ for _, org := range ownedOrgs {
+ if repo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, repo.ID) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/modules/repository/hooks.go b/modules/repository/hooks.go
new file mode 100644
index 0000000..9584978
--- /dev/null
+++ b/modules/repository/hooks.go
@@ -0,0 +1,233 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+func getHookTemplates() (hookNames, hookTpls, giteaHookTpls []string) {
+ hookNames = []string{"pre-receive", "update", "post-receive"}
+ hookTpls = []string{
+ // for pre-receive
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+ test -x "${hook}" && test -f "${hook}" || continue
+ echo "${data}" | "${hook}"
+ exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+ [ ${i} -eq 0 ] || exit ${i}
+done
+`, setting.ScriptType),
+
+ // for update
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+ test -x "${hook}" && test -f "${hook}" || continue
+ "${hook}" $1 $2 $3
+ exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+ [ ${i} -eq 0 ] || exit ${i}
+done
+`, setting.ScriptType),
+
+ // for post-receive
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+ test -x "${hook}" && test -f "${hook}" || continue
+ echo "${data}" | "${hook}"
+ exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+ [ ${i} -eq 0 ] || exit ${i}
+done
+`, setting.ScriptType),
+ }
+
+ giteaHookTpls = []string{
+ // for pre-receive
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+%s hook --config=%s pre-receive
+`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
+
+ // for update
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+%s hook --config=%s update $1 $2 $3
+`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
+
+ // for post-receive
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+%s hook --config=%s post-receive
+`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
+ }
+
+ // although only new git (>=2.29) supports proc-receive, it's still good to create its hook, in case the user upgrades git
+ hookNames = append(hookNames, "proc-receive")
+ hookTpls = append(hookTpls,
+ fmt.Sprintf(`#!/usr/bin/env %s
+# AUTO GENERATED BY GITEA, DO NOT MODIFY
+%s hook --config=%s proc-receive
+`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)))
+ giteaHookTpls = append(giteaHookTpls, "")
+
+ return hookNames, hookTpls, giteaHookTpls
+}
+
+// CreateDelegateHooks creates all the hooks scripts for the repo
+func CreateDelegateHooks(repoPath string) (err error) {
+ hookNames, hookTpls, giteaHookTpls := getHookTemplates()
+ hookDir := filepath.Join(repoPath, "hooks")
+
+ for i, hookName := range hookNames {
+ oldHookPath := filepath.Join(hookDir, hookName)
+ newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
+
+ if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil {
+ return fmt.Errorf("create hooks dir '%s': %w", filepath.Join(hookDir, hookName+".d"), err)
+ }
+
+ // WARNING: This will override all old server-side hooks
+ if err = util.Remove(oldHookPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
+ }
+ if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o777); err != nil {
+ return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
+ }
+
+ if err = ensureExecutable(oldHookPath); err != nil {
+ return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
+ }
+
+ if err = util.Remove(newHookPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
+ }
+ if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o777); err != nil {
+ return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
+ }
+
+ if err = ensureExecutable(newHookPath); err != nil {
+ return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
+ }
+ }
+
+ return nil
+}
+
+func checkExecutable(filename string) bool {
+ // windows has no concept of a executable bit
+ if runtime.GOOS == "windows" {
+ return true
+ }
+ fileInfo, err := os.Stat(filename)
+ if err != nil {
+ return false
+ }
+ return (fileInfo.Mode() & 0o100) > 0
+}
+
+func ensureExecutable(filename string) error {
+ fileInfo, err := os.Stat(filename)
+ if err != nil {
+ return err
+ }
+ if (fileInfo.Mode() & 0o100) > 0 {
+ return nil
+ }
+ mode := fileInfo.Mode() | 0o100
+ return os.Chmod(filename, mode)
+}
+
+// CheckDelegateHooks checks the hooks scripts for the repo
+func CheckDelegateHooks(repoPath string) ([]string, error) {
+ hookNames, hookTpls, giteaHookTpls := getHookTemplates()
+
+ hookDir := filepath.Join(repoPath, "hooks")
+ results := make([]string, 0, 10)
+
+ for i, hookName := range hookNames {
+ oldHookPath := filepath.Join(hookDir, hookName)
+ newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
+
+ cont := false
+ isExist, err := util.IsExist(oldHookPath)
+ if err != nil {
+ results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath, err))
+ }
+ if err == nil && !isExist {
+ results = append(results, fmt.Sprintf("old hook file %s does not exist", oldHookPath))
+ cont = true
+ }
+ isExist, err = util.IsExist(oldHookPath + ".d")
+ if err != nil {
+ results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath+".d", err))
+ }
+ if err == nil && !isExist {
+ results = append(results, fmt.Sprintf("hooks directory %s does not exist", oldHookPath+".d"))
+ cont = true
+ }
+ isExist, err = util.IsExist(newHookPath)
+ if err != nil {
+ results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", newHookPath, err))
+ }
+ if err == nil && !isExist {
+ results = append(results, fmt.Sprintf("new hook file %s does not exist", newHookPath))
+ cont = true
+ }
+ if cont {
+ continue
+ }
+ contents, err := os.ReadFile(oldHookPath)
+ if err != nil {
+ return results, err
+ }
+ if string(contents) != hookTpls[i] {
+ results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
+ }
+ if !checkExecutable(oldHookPath) {
+ results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
+ }
+ contents, err = os.ReadFile(newHookPath)
+ if err != nil {
+ return results, err
+ }
+ if string(contents) != giteaHookTpls[i] {
+ results = append(results, fmt.Sprintf("new hook file %s is out of date", newHookPath))
+ }
+ if !checkExecutable(newHookPath) {
+ results = append(results, fmt.Sprintf("new hook file %s is not executable", newHookPath))
+ }
+ }
+ return results, nil
+}
diff --git a/modules/repository/init.go b/modules/repository/init.go
new file mode 100644
index 0000000..5f500c5
--- /dev/null
+++ b/modules/repository/init.go
@@ -0,0 +1,182 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/label"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/options"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type OptionFile struct {
+ DisplayName string
+ Description string
+}
+
+var (
+ // Gitignores contains the gitiginore files
+ Gitignores []string
+
+ // Licenses contains the license files
+ Licenses []string
+
+ // Readmes contains the readme files
+ Readmes []string
+
+ // LabelTemplateFiles contains the label template files, each item has its DisplayName and Description
+ LabelTemplateFiles []OptionFile
+ labelTemplateFileMap = map[string]string{} // DisplayName => FileName mapping
+)
+
+type optionFileList struct {
+ all []string // all files provided by bindata & custom-path. Sorted.
+ custom []string // custom files provided by custom-path. Non-sorted, internal use only.
+}
+
+// mergeCustomLabelFiles merges the custom label files. Always use the file's main name (DisplayName) as the key to de-duplicate.
+func mergeCustomLabelFiles(fl optionFileList) []string {
+ exts := map[string]int{"": 0, ".yml": 1, ".yaml": 2} // "yaml" file has the highest priority to be used.
+
+ m := map[string]string{}
+ merge := func(list []string) {
+ sort.Slice(list, func(i, j int) bool { return exts[filepath.Ext(list[i])] < exts[filepath.Ext(list[j])] })
+ for _, f := range list {
+ m[strings.TrimSuffix(f, filepath.Ext(f))] = f
+ }
+ }
+ merge(fl.all)
+ merge(fl.custom)
+
+ files := make([]string, 0, len(m))
+ for _, f := range m {
+ files = append(files, f)
+ }
+ sort.Strings(files)
+ return files
+}
+
+// LoadRepoConfig loads the repository config
+func LoadRepoConfig() error {
+ types := []string{"gitignore", "license", "readme", "label"} // option file directories
+ typeFiles := make([]optionFileList, len(types))
+ for i, t := range types {
+ var err error
+ if typeFiles[i].all, err = options.AssetFS().ListFiles(t, true); err != nil {
+ return fmt.Errorf("failed to list %s files: %w", t, err)
+ }
+ sort.Strings(typeFiles[i].all)
+ customPath := filepath.Join(setting.CustomPath, "options", t)
+ if isDir, err := util.IsDir(customPath); err != nil {
+ return fmt.Errorf("failed to check custom %s dir: %w", t, err)
+ } else if isDir {
+ if typeFiles[i].custom, err = util.StatDir(customPath); err != nil {
+ return fmt.Errorf("failed to list custom %s files: %w", t, err)
+ }
+ }
+ }
+
+ Gitignores = typeFiles[0].all
+ Licenses = typeFiles[1].all
+ Readmes = typeFiles[2].all
+
+ // Load label templates
+ LabelTemplateFiles = nil
+ labelTemplateFileMap = map[string]string{}
+ for _, file := range mergeCustomLabelFiles(typeFiles[3]) {
+ description, err := label.LoadTemplateDescription(file)
+ if err != nil {
+ return fmt.Errorf("failed to load labels: %w", err)
+ }
+ displayName := strings.TrimSuffix(file, filepath.Ext(file))
+ labelTemplateFileMap[displayName] = file
+ LabelTemplateFiles = append(LabelTemplateFiles, OptionFile{DisplayName: displayName, Description: description})
+ }
+
+ // Filter out invalid names and promote preferred licenses.
+ sortedLicenses := make([]string, 0, len(Licenses))
+ for _, name := range setting.Repository.PreferredLicenses {
+ if util.SliceContainsString(Licenses, name, true) {
+ sortedLicenses = append(sortedLicenses, name)
+ }
+ }
+ for _, name := range Licenses {
+ if !util.SliceContainsString(setting.Repository.PreferredLicenses, name, true) {
+ sortedLicenses = append(sortedLicenses, name)
+ }
+ }
+ Licenses = sortedLicenses
+ return nil
+}
+
+func CheckInitRepository(ctx context.Context, owner, name, objectFormatName string) (err error) {
+ // Somehow the directory could exist.
+ repoPath := repo_model.RepoPath(owner, name)
+ isExist, err := util.IsExist(repoPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
+ return err
+ }
+ if isExist {
+ return repo_model.ErrRepoFilesAlreadyExist{
+ Uname: owner,
+ Name: name,
+ }
+ }
+
+ // Init git bare new repository.
+ if err = git.InitRepository(ctx, repoPath, true, objectFormatName); err != nil {
+ return fmt.Errorf("git.InitRepository: %w", err)
+ } else if err = CreateDelegateHooks(repoPath); err != nil {
+ return fmt.Errorf("createDelegateHooks: %w", err)
+ }
+ return nil
+}
+
+// InitializeLabels adds a label set to a repository using a template
+func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg bool) error {
+ list, err := LoadTemplateLabelsByDisplayName(labelTemplate)
+ if err != nil {
+ return err
+ }
+
+ labels := make([]*issues_model.Label, len(list))
+ for i := 0; i < len(list); i++ {
+ labels[i] = &issues_model.Label{
+ Name: list[i].Name,
+ Exclusive: list[i].Exclusive,
+ Description: list[i].Description,
+ Color: list[i].Color,
+ }
+ if isOrg {
+ labels[i].OrgID = id
+ } else {
+ labels[i].RepoID = id
+ }
+ }
+ for _, label := range labels {
+ if err = issues_model.NewLabel(ctx, label); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// LoadTemplateLabelsByDisplayName loads a label template by its display name
+func LoadTemplateLabelsByDisplayName(displayName string) ([]*label.Label, error) {
+ if fileName, ok := labelTemplateFileMap[displayName]; ok {
+ return label.LoadTemplateFile(fileName)
+ }
+ return nil, label.ErrTemplateLoad{TemplateFile: displayName, OriginalError: fmt.Errorf("label template %q not found", displayName)}
+}
diff --git a/modules/repository/init_test.go b/modules/repository/init_test.go
new file mode 100644
index 0000000..227efdc
--- /dev/null
+++ b/modules/repository/init_test.go
@@ -0,0 +1,30 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMergeCustomLabels(t *testing.T) {
+ files := mergeCustomLabelFiles(optionFileList{
+ all: []string{"a", "a.yaml", "a.yml"},
+ custom: nil,
+ })
+ assert.EqualValues(t, []string{"a.yaml"}, files, "yaml file should win")
+
+ files = mergeCustomLabelFiles(optionFileList{
+ all: []string{"a", "a.yaml"},
+ custom: []string{"a"},
+ })
+ assert.EqualValues(t, []string{"a"}, files, "custom file should win")
+
+ files = mergeCustomLabelFiles(optionFileList{
+ all: []string{"a", "a.yml", "a.yaml"},
+ custom: []string{"a", "a.yml"},
+ })
+ assert.EqualValues(t, []string{"a.yml"}, files, "custom yml file should win if no yaml")
+}
diff --git a/modules/repository/license.go b/modules/repository/license.go
new file mode 100644
index 0000000..07ae92c
--- /dev/null
+++ b/modules/repository/license.go
@@ -0,0 +1,112 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/options"
+)
+
+type LicenseValues struct {
+ Owner string
+ Email string
+ Repo string
+ Year string
+}
+
+func GetLicense(name string, values *LicenseValues) ([]byte, error) {
+ data, err := options.License(name)
+ if err != nil {
+ return nil, fmt.Errorf("GetRepoInitFile[%s]: %w", name, err)
+ }
+ return fillLicensePlaceholder(name, values, data), nil
+}
+
+func fillLicensePlaceholder(name string, values *LicenseValues, origin []byte) []byte {
+ placeholder := getLicensePlaceholder(name)
+
+ scanner := bufio.NewScanner(bytes.NewReader(origin))
+ output := bytes.NewBuffer(nil)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if placeholder.MatchLine == nil || placeholder.MatchLine.MatchString(line) {
+ for _, v := range placeholder.Owner {
+ line = strings.ReplaceAll(line, v, values.Owner)
+ }
+ for _, v := range placeholder.Email {
+ line = strings.ReplaceAll(line, v, values.Email)
+ }
+ for _, v := range placeholder.Repo {
+ line = strings.ReplaceAll(line, v, values.Repo)
+ }
+ for _, v := range placeholder.Year {
+ line = strings.ReplaceAll(line, v, values.Year)
+ }
+ }
+ output.WriteString(line + "\n")
+ }
+
+ return output.Bytes()
+}
+
+type licensePlaceholder struct {
+ Owner []string
+ Email []string
+ Repo []string
+ Year []string
+ MatchLine *regexp.Regexp
+}
+
+func getLicensePlaceholder(name string) *licensePlaceholder {
+ // Some universal placeholders.
+ // If you want to add a new one, make sure you have check it by `grep -r 'NEW_WORD' options/license` and all of them are placeholders.
+ ret := &licensePlaceholder{
+ Owner: []string{
+ "<name of author>",
+ "<owner>",
+ "[NAME]",
+ "[name of copyright owner]",
+ "[name of copyright holder]",
+ "<COPYRIGHT HOLDERS>",
+ "<copyright holders>",
+ "<AUTHOR>",
+ "<author's name or designee>",
+ "[one or more legally recognised persons or entities offering the Work under the terms and conditions of this Licence]",
+ },
+ Email: []string{
+ "[EMAIL]",
+ },
+ Repo: []string{
+ "<program>",
+ "<one line to give the program's name and a brief idea of what it does.>",
+ },
+ Year: []string{
+ "<year>",
+ "[YEAR]",
+ "{YEAR}",
+ "[yyyy]",
+ "[Year]",
+ "[year]",
+ },
+ }
+
+ // Some special placeholders for specific licenses.
+ // It's unsafe to apply them to all licenses.
+ if name == "0BSD" {
+ return &licensePlaceholder{
+ Owner: []string{"AUTHOR"},
+ Email: []string{"EMAIL"},
+ Year: []string{"YEAR"},
+ MatchLine: regexp.MustCompile(`Copyright \(C\) YEAR by AUTHOR EMAIL`), // there is another AUTHOR in the file, but it's not a placeholder
+ }
+
+ // Other special placeholders can be added here.
+ }
+ return ret
+}
diff --git a/modules/repository/license_test.go b/modules/repository/license_test.go
new file mode 100644
index 0000000..a7d7774
--- /dev/null
+++ b/modules/repository/license_test.go
@@ -0,0 +1,181 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getLicense(t *testing.T) {
+ type args struct {
+ name string
+ values *LicenseValues
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr require.ErrorAssertionFunc
+ }{
+ {
+ name: "regular",
+ args: args{
+ name: "MIT",
+ values: &LicenseValues{Owner: "Gitea", Year: "2023"},
+ },
+ want: `MIT License
+
+Copyright (c) 2023 Gitea
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+`,
+ wantErr: require.NoError,
+ },
+ {
+ name: "license not found",
+ args: args{
+ name: "notfound",
+ },
+ wantErr: require.Error,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := GetLicense(tt.args.name, tt.args.values)
+ tt.wantErr(t, err, fmt.Sprintf("GetLicense(%v, %v)", tt.args.name, tt.args.values))
+
+ assert.Equalf(t, tt.want, string(got), "GetLicense(%v, %v)", tt.args.name, tt.args.values)
+ })
+ }
+}
+
+func Test_fillLicensePlaceholder(t *testing.T) {
+ type args struct {
+ name string
+ values *LicenseValues
+ origin string
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ name: "owner",
+ args: args{
+ name: "regular",
+ values: &LicenseValues{Year: "2023", Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea"},
+ origin: `
+<name of author>
+<owner>
+[NAME]
+[name of copyright owner]
+[name of copyright holder]
+<COPYRIGHT HOLDERS>
+<copyright holders>
+<AUTHOR>
+<author's name or designee>
+[one or more legally recognised persons or entities offering the Work under the terms and conditions of this Licence]
+`,
+ },
+ want: `
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+Gitea
+`,
+ },
+ {
+ name: "email",
+ args: args{
+ name: "regular",
+ values: &LicenseValues{Year: "2023", Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea"},
+ origin: `
+[EMAIL]
+`,
+ },
+ want: `
+teabot@gitea.io
+`,
+ },
+ {
+ name: "repo",
+ args: args{
+ name: "regular",
+ values: &LicenseValues{Year: "2023", Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea"},
+ origin: `
+<program>
+<one line to give the program's name and a brief idea of what it does.>
+`,
+ },
+ want: `
+gitea
+gitea
+`,
+ },
+ {
+ name: "year",
+ args: args{
+ name: "regular",
+ values: &LicenseValues{Year: "2023", Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea"},
+ origin: `
+<year>
+[YEAR]
+{YEAR}
+[yyyy]
+[Year]
+[year]
+`,
+ },
+ want: `
+2023
+2023
+2023
+2023
+2023
+2023
+`,
+ },
+ {
+ name: "0BSD",
+ args: args{
+ name: "0BSD",
+ values: &LicenseValues{Year: "2023", Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea"},
+ origin: `
+Copyright (C) YEAR by AUTHOR EMAIL
+
+...
+
+... THE AUTHOR BE LIABLE FOR ...
+`,
+ },
+ want: `
+Copyright (C) 2023 by Gitea teabot@gitea.io
+
+...
+
+... THE AUTHOR BE LIABLE FOR ...
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, string(fillLicensePlaceholder(tt.args.name, tt.args.values, []byte(tt.args.origin))), "fillLicensePlaceholder(%v, %v, %v)", tt.args.name, tt.args.values, tt.args.origin)
+ })
+ }
+}
diff --git a/modules/repository/main_test.go b/modules/repository/main_test.go
new file mode 100644
index 0000000..f81dfcd
--- /dev/null
+++ b/modules/repository/main_test.go
@@ -0,0 +1,16 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models/actions"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/modules/repository/push.go b/modules/repository/push.go
new file mode 100644
index 0000000..66d0417
--- /dev/null
+++ b/modules/repository/push.go
@@ -0,0 +1,70 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "code.gitea.io/gitea/modules/git"
+)
+
+// PushUpdateOptions defines the push update options
+type PushUpdateOptions struct {
+ PusherID int64
+ PusherName string
+ RepoUserName string
+ RepoName string
+ RefFullName git.RefName // branch, tag or other name to push
+ OldCommitID string
+ NewCommitID string
+ TimeNano int64
+}
+
+// IsNewRef return true if it's a first-time push to a branch, tag or etc.
+func (opts *PushUpdateOptions) IsNewRef() bool {
+ return git.IsEmptyCommitID(opts.OldCommitID, nil)
+}
+
+// IsDelRef return true if it's a deletion to a branch or tag
+func (opts *PushUpdateOptions) IsDelRef() bool {
+ return git.IsEmptyCommitID(opts.NewCommitID, nil)
+}
+
+// IsUpdateRef return true if it's an update operation
+func (opts *PushUpdateOptions) IsUpdateRef() bool {
+ return !opts.IsNewRef() && !opts.IsDelRef()
+}
+
+// IsNewTag return true if it's a creation to a tag
+func (opts *PushUpdateOptions) IsNewTag() bool {
+ return opts.RefFullName.IsTag() && opts.IsNewRef()
+}
+
+// IsDelTag return true if it's a deletion to a tag
+func (opts *PushUpdateOptions) IsDelTag() bool {
+ return opts.RefFullName.IsTag() && opts.IsDelRef()
+}
+
+// IsNewBranch return true if it's the first-time push to a branch
+func (opts *PushUpdateOptions) IsNewBranch() bool {
+ return opts.RefFullName.IsBranch() && opts.IsNewRef()
+}
+
+// IsUpdateBranch return true if it's not the first push to a branch
+func (opts *PushUpdateOptions) IsUpdateBranch() bool {
+ return opts.RefFullName.IsBranch() && opts.IsUpdateRef()
+}
+
+// IsDelBranch return true if it's a deletion to a branch
+func (opts *PushUpdateOptions) IsDelBranch() bool {
+ return opts.RefFullName.IsBranch() && opts.IsDelRef()
+}
+
+// RefName returns simple name for ref
+func (opts *PushUpdateOptions) RefName() string {
+ return opts.RefFullName.ShortName()
+}
+
+// RepoFullName returns repo full name
+func (opts *PushUpdateOptions) RepoFullName() string {
+ return opts.RepoUserName + "/" + opts.RepoName
+}
diff --git a/modules/repository/repo.go b/modules/repository/repo.go
new file mode 100644
index 0000000..e08bc37
--- /dev/null
+++ b/modules/repository/repo.go
@@ -0,0 +1,388 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+/*
+GitHub, GitLab, Gogs: *.wiki.git
+BitBucket: *.git/wiki
+*/
+var commonWikiURLSuffixes = []string{".wiki.git", ".git/wiki"}
+
+// WikiRemoteURL returns accessible repository URL for wiki if exists.
+// Otherwise, it returns an empty string.
+func WikiRemoteURL(ctx context.Context, remote string) string {
+ remote = strings.TrimSuffix(remote, ".git")
+ for _, suffix := range commonWikiURLSuffixes {
+ wikiURL := remote + suffix
+ if git.IsRepoURLAccessible(ctx, wikiURL) {
+ return wikiURL
+ }
+ }
+ return ""
+}
+
+// SyncRepoTags synchronizes releases table with repository tags
+func SyncRepoTags(ctx context.Context, repoID int64) error {
+ repo, err := repo_model.GetRepositoryByID(ctx, repoID)
+ if err != nil {
+ return err
+ }
+
+ gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ return err
+ }
+ defer gitRepo.Close()
+
+ return SyncReleasesWithTags(ctx, repo, gitRepo)
+}
+
+// SyncReleasesWithTags synchronizes release table with repository tags
+func SyncReleasesWithTags(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository) error {
+ log.Debug("SyncReleasesWithTags: in Repo[%d:%s/%s]", repo.ID, repo.OwnerName, repo.Name)
+
+ // optimized procedure for pull-mirrors which saves a lot of time (in
+ // particular for repos with many tags).
+ if repo.IsMirror {
+ return pullMirrorReleaseSync(ctx, repo, gitRepo)
+ }
+
+ existingRelTags := make(container.Set[string])
+ opts := repo_model.FindReleasesOptions{
+ IncludeDrafts: true,
+ IncludeTags: true,
+ ListOptions: db.ListOptions{PageSize: 50},
+ RepoID: repo.ID,
+ }
+ for page := 1; ; page++ {
+ opts.Page = page
+ rels, err := db.Find[repo_model.Release](gitRepo.Ctx, opts)
+ if err != nil {
+ return fmt.Errorf("unable to GetReleasesByRepoID in Repo[%d:%s/%s]: %w", repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ if len(rels) == 0 {
+ break
+ }
+ for _, rel := range rels {
+ if rel.IsDraft {
+ continue
+ }
+ commitID, err := gitRepo.GetTagCommitID(rel.TagName)
+ if err != nil && !git.IsErrNotExist(err) {
+ return fmt.Errorf("unable to GetTagCommitID for %q in Repo[%d:%s/%s]: %w", rel.TagName, repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ if git.IsErrNotExist(err) || commitID != rel.Sha1 {
+ if err := repo_model.PushUpdateDeleteTag(ctx, repo, rel.TagName); err != nil {
+ return fmt.Errorf("unable to PushUpdateDeleteTag: %q in Repo[%d:%s/%s]: %w", rel.TagName, repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ } else {
+ existingRelTags.Add(strings.ToLower(rel.TagName))
+ }
+ }
+ }
+
+ _, err := gitRepo.WalkReferences(git.ObjectTag, 0, 0, func(sha1, refname string) error {
+ tagName := strings.TrimPrefix(refname, git.TagPrefix)
+ if existingRelTags.Contains(strings.ToLower(tagName)) {
+ return nil
+ }
+
+ if err := PushUpdateAddTag(ctx, repo, gitRepo, tagName, sha1, refname); err != nil {
+ // sometimes, some tags will be sync failed. i.e. https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tag/?h=v2.6.11
+ // this is a tree object, not a tag object which created before git
+ log.Error("unable to PushUpdateAddTag: %q to Repo[%d:%s/%s]: %v", tagName, repo.ID, repo.OwnerName, repo.Name, err)
+ }
+
+ return nil
+ })
+ return err
+}
+
+// PushUpdateAddTag must be called for any push actions to add tag
+func PushUpdateAddTag(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, tagName, sha1, refname string) error {
+ tag, err := gitRepo.GetTagWithID(sha1, tagName)
+ if err != nil {
+ return fmt.Errorf("unable to GetTag: %w", err)
+ }
+ commit, err := tag.Commit(gitRepo)
+ if err != nil {
+ return fmt.Errorf("unable to get tag Commit: %w", err)
+ }
+
+ sig := tag.Tagger
+ if sig == nil {
+ sig = commit.Author
+ }
+ if sig == nil {
+ sig = commit.Committer
+ }
+
+ var author *user_model.User
+ createdAt := time.Unix(1, 0)
+
+ if sig != nil {
+ author, err = user_model.GetUserByEmail(ctx, sig.Email)
+ if err != nil && !user_model.IsErrUserNotExist(err) {
+ return fmt.Errorf("unable to GetUserByEmail for %q: %w", sig.Email, err)
+ }
+ createdAt = sig.When
+ }
+
+ commitsCount, err := commit.CommitsCount()
+ if err != nil {
+ return fmt.Errorf("unable to get CommitsCount: %w", err)
+ }
+
+ rel := repo_model.Release{
+ RepoID: repo.ID,
+ TagName: tagName,
+ LowerTagName: strings.ToLower(tagName),
+ Sha1: commit.ID.String(),
+ NumCommits: commitsCount,
+ CreatedUnix: timeutil.TimeStamp(createdAt.Unix()),
+ IsTag: true,
+ }
+ if author != nil {
+ rel.PublisherID = author.ID
+ }
+
+ return repo_model.SaveOrUpdateTag(ctx, repo, &rel)
+}
+
+// StoreMissingLfsObjectsInRepository downloads missing LFS objects
+func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, lfsClient lfs.Client) error {
+ contentStore := lfs.NewContentStore()
+
+ pointerChan := make(chan lfs.PointerBlob)
+ errChan := make(chan error, 1)
+ go lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan, errChan)
+
+ downloadObjects := func(pointers []lfs.Pointer) error {
+ err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
+ if objectError != nil {
+ if errors.Is(objectError, lfs.ErrObjectNotExist) {
+ log.Warn("Repo[%-v]: Ignore missing LFS object %-v: %v", repo, p, objectError)
+ return nil
+ }
+ return objectError
+ }
+
+ defer content.Close()
+
+ _, err := git_model.NewLFSMetaObject(ctx, repo.ID, p)
+ if err != nil {
+ log.Error("Repo[%-v]: Error creating LFS meta object %-v: %v", repo, p, err)
+ return err
+ }
+
+ if err := contentStore.Put(p, content); err != nil {
+ log.Error("Repo[%-v]: Error storing content for LFS meta object %-v: %v", repo, p, err)
+ if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repo.ID, p.Oid); err2 != nil {
+ log.Error("Repo[%-v]: Error removing LFS meta object %-v: %v", repo, p, err2)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ }
+ }
+ return err
+ }
+
+ var batch []lfs.Pointer
+ for pointerBlob := range pointerChan {
+ meta, err := git_model.GetLFSMetaObjectByOid(ctx, repo.ID, pointerBlob.Oid)
+ if err != nil && err != git_model.ErrLFSObjectNotExist {
+ log.Error("Repo[%-v]: Error querying LFS meta object %-v: %v", repo, pointerBlob.Pointer, err)
+ return err
+ }
+ if meta != nil {
+ log.Trace("Repo[%-v]: Skipping unknown LFS meta object %-v", repo, pointerBlob.Pointer)
+ continue
+ }
+
+ log.Trace("Repo[%-v]: LFS object %-v not present in repository", repo, pointerBlob.Pointer)
+
+ exist, err := contentStore.Exists(pointerBlob.Pointer)
+ if err != nil {
+ log.Error("Repo[%-v]: Error checking if LFS object %-v exists: %v", repo, pointerBlob.Pointer, err)
+ return err
+ }
+
+ if exist {
+ log.Trace("Repo[%-v]: LFS object %-v already present; creating meta object", repo, pointerBlob.Pointer)
+ _, err := git_model.NewLFSMetaObject(ctx, repo.ID, pointerBlob.Pointer)
+ if err != nil {
+ log.Error("Repo[%-v]: Error creating LFS meta object %-v: %v", repo, pointerBlob.Pointer, err)
+ return err
+ }
+ } else {
+ if setting.LFS.MaxFileSize > 0 && pointerBlob.Size > setting.LFS.MaxFileSize {
+ log.Info("Repo[%-v]: LFS object %-v download denied because of LFS_MAX_FILE_SIZE=%d < size %d", repo, pointerBlob.Pointer, setting.LFS.MaxFileSize, pointerBlob.Size)
+ continue
+ }
+
+ batch = append(batch, pointerBlob.Pointer)
+ if len(batch) >= lfsClient.BatchSize() {
+ if err := downloadObjects(batch); err != nil {
+ return err
+ }
+ batch = nil
+ }
+ }
+ }
+ if len(batch) > 0 {
+ if err := downloadObjects(batch); err != nil {
+ return err
+ }
+ }
+
+ err, has := <-errChan
+ if has {
+ log.Error("Repo[%-v]: Error enumerating LFS objects for repository: %v", repo, err)
+ return err
+ }
+
+ return nil
+}
+
+// shortRelease to reduce load memory, this struct can replace repo_model.Release
+type shortRelease struct {
+ ID int64
+ TagName string
+ Sha1 string
+ IsTag bool
+}
+
+func (shortRelease) TableName() string {
+ return "release"
+}
+
+// pullMirrorReleaseSync is a pull-mirror specific tag<->release table
+// synchronization which overwrites all Releases from the repository tags. This
+// can be relied on since a pull-mirror is always identical to its
+// upstream. Hence, after each sync we want the pull-mirror release set to be
+// identical to the upstream tag set. This is much more efficient for
+// repositories like https://github.com/vim/vim (with over 13000 tags).
+func pullMirrorReleaseSync(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository) error {
+ log.Trace("pullMirrorReleaseSync: rebuilding releases for pull-mirror Repo[%d:%s/%s]", repo.ID, repo.OwnerName, repo.Name)
+ tags, numTags, err := gitRepo.GetTagInfos(0, 0)
+ if err != nil {
+ return fmt.Errorf("unable to GetTagInfos in pull-mirror Repo[%d:%s/%s]: %w", repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ err = db.WithTx(ctx, func(ctx context.Context) error {
+ dbReleases, err := db.Find[shortRelease](ctx, repo_model.FindReleasesOptions{
+ RepoID: repo.ID,
+ IncludeDrafts: true,
+ IncludeTags: true,
+ })
+ if err != nil {
+ return fmt.Errorf("unable to FindReleases in pull-mirror Repo[%d:%s/%s]: %w", repo.ID, repo.OwnerName, repo.Name, err)
+ }
+
+ inserts, deletes, updates := calcSync(tags, dbReleases)
+ //
+ // make release set identical to upstream tags
+ //
+ for _, tag := range inserts {
+ release := repo_model.Release{
+ RepoID: repo.ID,
+ TagName: tag.Name,
+ LowerTagName: strings.ToLower(tag.Name),
+ Sha1: tag.Object.String(),
+ // NOTE: ignored, since NumCommits are unused
+ // for pull-mirrors (only relevant when
+ // displaying releases, IsTag: false)
+ NumCommits: -1,
+ CreatedUnix: timeutil.TimeStamp(tag.Tagger.When.Unix()),
+ IsTag: true,
+ }
+ if err := db.Insert(ctx, release); err != nil {
+ return fmt.Errorf("unable insert tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ }
+
+ // only delete tags releases
+ if len(deletes) > 0 {
+ if _, err := db.GetEngine(ctx).Where("repo_id=?", repo.ID).
+ In("id", deletes).
+ Delete(&repo_model.Release{}); err != nil {
+ return fmt.Errorf("unable to delete tags for pull-mirror Repo[%d:%s/%s]: %w", repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ }
+
+ for _, tag := range updates {
+ if _, err := db.GetEngine(ctx).Where("repo_id = ? AND lower_tag_name = ?", repo.ID, strings.ToLower(tag.Name)).
+ Cols("sha1").
+ Update(&repo_model.Release{
+ Sha1: tag.Object.String(),
+ }); err != nil {
+ return fmt.Errorf("unable to update tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("unable to rebuild release table for pull-mirror Repo[%d:%s/%s]: %w", repo.ID, repo.OwnerName, repo.Name, err)
+ }
+
+ log.Trace("pullMirrorReleaseSync: done rebuilding %d releases", numTags)
+ return nil
+}
+
+func calcSync(destTags []*git.Tag, dbTags []*shortRelease) ([]*git.Tag, []int64, []*git.Tag) {
+ destTagMap := make(map[string]*git.Tag)
+ for _, tag := range destTags {
+ destTagMap[tag.Name] = tag
+ }
+ dbTagMap := make(map[string]*shortRelease)
+ for _, rel := range dbTags {
+ dbTagMap[rel.TagName] = rel
+ }
+
+ inserted := make([]*git.Tag, 0, 10)
+ updated := make([]*git.Tag, 0, 10)
+ for _, tag := range destTags {
+ rel := dbTagMap[tag.Name]
+ if rel == nil {
+ inserted = append(inserted, tag)
+ } else if rel.Sha1 != tag.Object.String() {
+ updated = append(updated, tag)
+ }
+ }
+ deleted := make([]int64, 0, 10)
+ for _, tag := range dbTags {
+ if destTagMap[tag.TagName] == nil && tag.IsTag {
+ deleted = append(deleted, tag.ID)
+ }
+ }
+ return inserted, deleted, updated
+}
diff --git a/modules/repository/repo_test.go b/modules/repository/repo_test.go
new file mode 100644
index 0000000..f3e7be6
--- /dev/null
+++ b/modules/repository/repo_test.go
@@ -0,0 +1,76 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_calcSync(t *testing.T) {
+ gitTags := []*git.Tag{
+ /*{
+ Name: "v0.1.0-beta", //deleted tag
+ Object: git.MustIDFromString(""),
+ },
+ {
+ Name: "v0.1.1-beta", //deleted tag but release should not be deleted because it's a release
+ Object: git.MustIDFromString(""),
+ },
+ */
+ {
+ Name: "v1.0.0", // keep as before
+ Object: git.MustIDFromString("1006e6e13c73ad3d9e2d5682ad266b5016523485"),
+ },
+ {
+ Name: "v1.1.0", // retagged with new commit id
+ Object: git.MustIDFromString("bbdb7df30248e7d4a26a909c8d2598a152e13868"),
+ },
+ {
+ Name: "v1.2.0", // new tag
+ Object: git.MustIDFromString("a5147145e2f24d89fd6d2a87826384cc1d253267"),
+ },
+ }
+
+ dbReleases := []*shortRelease{
+ {
+ ID: 1,
+ TagName: "v0.1.0-beta",
+ Sha1: "244758d7da8dd1d9e0727e8cb7704ed4ba9a17c3",
+ IsTag: true,
+ },
+ {
+ ID: 2,
+ TagName: "v0.1.1-beta",
+ Sha1: "244758d7da8dd1d9e0727e8cb7704ed4ba9a17c3",
+ IsTag: false,
+ },
+ {
+ ID: 3,
+ TagName: "v1.0.0",
+ Sha1: "1006e6e13c73ad3d9e2d5682ad266b5016523485",
+ },
+ {
+ ID: 4,
+ TagName: "v1.1.0",
+ Sha1: "53ab18dcecf4152b58328d1f47429510eb414d50",
+ },
+ }
+
+ inserts, deletes, updates := calcSync(gitTags, dbReleases)
+ if assert.Len(t, inserts, 1, "inserts") {
+ assert.EqualValues(t, *gitTags[2], *inserts[0], "inserts equal")
+ }
+
+ if assert.Len(t, deletes, 1, "deletes") {
+ assert.EqualValues(t, 1, deletes[0], "deletes equal")
+ }
+
+ if assert.Len(t, updates, 1, "updates") {
+ assert.EqualValues(t, *gitTags[1], *updates[0], "updates equal")
+ }
+}
diff --git a/modules/repository/temp.go b/modules/repository/temp.go
new file mode 100644
index 0000000..04faa9d
--- /dev/null
+++ b/modules/repository/temp.go
@@ -0,0 +1,45 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// LocalCopyPath returns the local repository temporary copy path.
+func LocalCopyPath() string {
+ if filepath.IsAbs(setting.Repository.Local.LocalCopyPath) {
+ return setting.Repository.Local.LocalCopyPath
+ }
+ return path.Join(setting.AppDataPath, setting.Repository.Local.LocalCopyPath)
+}
+
+// CreateTemporaryPath creates a temporary path
+func CreateTemporaryPath(prefix string) (string, error) {
+ if err := os.MkdirAll(LocalCopyPath(), os.ModePerm); err != nil {
+ log.Error("Unable to create localcopypath directory: %s (%v)", LocalCopyPath(), err)
+ return "", fmt.Errorf("Failed to create localcopypath directory %s: %w", LocalCopyPath(), err)
+ }
+ basePath, err := os.MkdirTemp(LocalCopyPath(), prefix+".git")
+ if err != nil {
+ log.Error("Unable to create temporary directory: %s-*.git (%v)", prefix, err)
+ return "", fmt.Errorf("Failed to create dir %s-*.git: %w", prefix, err)
+ }
+ return basePath, nil
+}
+
+// RemoveTemporaryPath removes the temporary path
+func RemoveTemporaryPath(basePath string) error {
+ if _, err := os.Stat(basePath); !os.IsNotExist(err) {
+ return util.RemoveAll(basePath)
+ }
+ return nil
+}
diff --git a/modules/secret/secret.go b/modules/secret/secret.go
new file mode 100644
index 0000000..e70ae18
--- /dev/null
+++ b/modules/secret/secret.go
@@ -0,0 +1,78 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package secret
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+)
+
+// AesEncrypt encrypts text and given key with AES.
+func AesEncrypt(key, text []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("AesEncrypt invalid key: %v", err)
+ }
+ b := base64.StdEncoding.EncodeToString(text)
+ ciphertext := make([]byte, aes.BlockSize+len(b))
+ iv := ciphertext[:aes.BlockSize]
+ if _, err = io.ReadFull(rand.Reader, iv); err != nil {
+ return nil, fmt.Errorf("AesEncrypt unable to read IV: %w", err)
+ }
+ cfb := cipher.NewCFBEncrypter(block, iv)
+ cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
+ return ciphertext, nil
+}
+
+// AesDecrypt decrypts text and given key with AES.
+func AesDecrypt(key, text []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ if len(text) < aes.BlockSize {
+ return nil, errors.New("AesDecrypt ciphertext too short")
+ }
+ iv := text[:aes.BlockSize]
+ text = text[aes.BlockSize:]
+ cfb := cipher.NewCFBDecrypter(block, iv)
+ cfb.XORKeyStream(text, text)
+ data, err := base64.StdEncoding.DecodeString(string(text))
+ if err != nil {
+ return nil, fmt.Errorf("AesDecrypt invalid decrypted base64 string: %w", err)
+ }
+ return data, nil
+}
+
+// EncryptSecret encrypts a string with given key into a hex string
+func EncryptSecret(key, str string) (string, error) {
+ keyHash := sha256.Sum256([]byte(key))
+ plaintext := []byte(str)
+ ciphertext, err := AesEncrypt(keyHash[:], plaintext)
+ if err != nil {
+ return "", fmt.Errorf("failed to encrypt by secret: %w", err)
+ }
+ return hex.EncodeToString(ciphertext), nil
+}
+
+// DecryptSecret decrypts a previously encrypted hex string
+func DecryptSecret(key, cipherHex string) (string, error) {
+ keyHash := sha256.Sum256([]byte(key))
+ ciphertext, err := hex.DecodeString(cipherHex)
+ if err != nil {
+ return "", fmt.Errorf("failed to decrypt by secret, invalid hex string: %w", err)
+ }
+ plaintext, err := AesDecrypt(keyHash[:], ciphertext)
+ if err != nil {
+ return "", fmt.Errorf("failed to decrypt by secret, the key (maybe SECRET_KEY?) might be incorrect: %w", err)
+ }
+ return string(plaintext), nil
+}
diff --git a/modules/secret/secret_test.go b/modules/secret/secret_test.go
new file mode 100644
index 0000000..ba23718
--- /dev/null
+++ b/modules/secret/secret_test.go
@@ -0,0 +1,32 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package secret
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEncryptDecrypt(t *testing.T) {
+ hex, err := EncryptSecret("foo", "baz")
+ require.NoError(t, err)
+ str, _ := DecryptSecret("foo", hex)
+ assert.Equal(t, "baz", str)
+
+ hex, err = EncryptSecret("bar", "baz")
+ require.NoError(t, err)
+ str, _ = DecryptSecret("foo", hex)
+ assert.NotEqual(t, "baz", str)
+
+ _, err = DecryptSecret("a", "b")
+ require.ErrorContains(t, err, "invalid hex string")
+
+ _, err = DecryptSecret("a", "bb")
+ require.ErrorContains(t, err, "the key (maybe SECRET_KEY?) might be incorrect: AesDecrypt ciphertext too short")
+
+ _, err = DecryptSecret("a", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
+ require.ErrorContains(t, err, "the key (maybe SECRET_KEY?) might be incorrect: AesDecrypt invalid decrypted base64 string")
+}
diff --git a/modules/session/db.go b/modules/session/db.go
new file mode 100644
index 0000000..3b12b93
--- /dev/null
+++ b/modules/session/db.go
@@ -0,0 +1,171 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package session
+
+import (
+ "log"
+ "sync"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "code.forgejo.org/go-chi/session"
+)
+
+// DBStore represents a session store implementation based on the DB.
+type DBStore struct {
+ sid string
+ lock sync.RWMutex
+ data map[any]any
+}
+
+// NewDBStore creates and returns a DB session store.
+func NewDBStore(sid string, kv map[any]any) *DBStore {
+ return &DBStore{
+ sid: sid,
+ data: kv,
+ }
+}
+
+// Set sets value to given key in session.
+func (s *DBStore) Set(key, val any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data[key] = val
+ return nil
+}
+
+// Get gets value by given key in session.
+func (s *DBStore) Get(key any) any {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ return s.data[key]
+}
+
+// Delete delete a key from session.
+func (s *DBStore) Delete(key any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ delete(s.data, key)
+ return nil
+}
+
+// ID returns current session ID.
+func (s *DBStore) ID() string {
+ return s.sid
+}
+
+// Release releases resource and save data to provider.
+func (s *DBStore) Release() error {
+ // Skip encoding if the data is empty
+ if len(s.data) == 0 {
+ return nil
+ }
+
+ data, err := session.EncodeGob(s.data)
+ if err != nil {
+ return err
+ }
+
+ return auth.UpdateSession(db.DefaultContext, s.sid, data)
+}
+
+// Flush deletes all session data.
+func (s *DBStore) Flush() error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data = make(map[any]any)
+ return nil
+}
+
+// DBProvider represents a DB session provider implementation.
+type DBProvider struct {
+ maxLifetime int64
+}
+
+// Init initializes DB session provider.
+// connStr: username:password@protocol(address)/dbname?param=value
+func (p *DBProvider) Init(maxLifetime int64, connStr string) error {
+ p.maxLifetime = maxLifetime
+ return nil
+}
+
+// Read returns raw session store by session ID.
+func (p *DBProvider) Read(sid string) (session.RawStore, error) {
+ s, err := auth.ReadSession(db.DefaultContext, sid)
+ if err != nil {
+ return nil, err
+ }
+
+ var kv map[any]any
+ if len(s.Data) == 0 || s.Expiry.Add(p.maxLifetime) <= timeutil.TimeStampNow() {
+ kv = make(map[any]any)
+ } else {
+ kv, err = session.DecodeGob(s.Data)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return NewDBStore(sid, kv), nil
+}
+
+// Exist returns true if session with given ID exists.
+func (p *DBProvider) Exist(sid string) bool {
+ has, err := auth.ExistSession(db.DefaultContext, sid)
+ if err != nil {
+ panic("session/DB: error checking existence: " + err.Error())
+ }
+ return has
+}
+
+// Destroy deletes a session by session ID.
+func (p *DBProvider) Destroy(sid string) error {
+ return auth.DestroySession(db.DefaultContext, sid)
+}
+
+// Regenerate regenerates a session store from old session ID to new one.
+func (p *DBProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err error) {
+ s, err := auth.RegenerateSession(db.DefaultContext, oldsid, sid)
+ if err != nil {
+ return nil, err
+ }
+
+ var kv map[any]any
+ if len(s.Data) == 0 || s.Expiry.Add(p.maxLifetime) <= timeutil.TimeStampNow() {
+ kv = make(map[any]any)
+ } else {
+ kv, err = session.DecodeGob(s.Data)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return NewDBStore(sid, kv), nil
+}
+
+// Count counts and returns number of sessions.
+func (p *DBProvider) Count() int {
+ total, err := auth.CountSessions(db.DefaultContext)
+ if err != nil {
+ panic("session/DB: error counting records: " + err.Error())
+ }
+ return int(total)
+}
+
+// GC calls GC to clean expired sessions.
+func (p *DBProvider) GC() {
+ if err := auth.CleanupSessions(db.DefaultContext, p.maxLifetime); err != nil {
+ log.Printf("session/DB: error garbage collecting: %v", err)
+ }
+}
+
+func init() {
+ session.Register("db", &DBProvider{})
+}
diff --git a/modules/session/redis.go b/modules/session/redis.go
new file mode 100644
index 0000000..230b501
--- /dev/null
+++ b/modules/session/redis.go
@@ -0,0 +1,225 @@
+// Copyright 2013 Beego Authors
+// Copyright 2014 The Macaron Authors
+// Copyright 2020 The Gitea Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"): you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+// SPDX-License-Identifier: Apache-2.0
+
+package session
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/nosql"
+
+ "code.forgejo.org/go-chi/session"
+)
+
+// RedisStore represents a redis session store implementation.
+type RedisStore struct {
+ c nosql.RedisClient
+ prefix, sid string
+ duration time.Duration
+ lock sync.RWMutex
+ data map[any]any
+}
+
+// NewRedisStore creates and returns a redis session store.
+func NewRedisStore(c nosql.RedisClient, prefix, sid string, dur time.Duration, kv map[any]any) *RedisStore {
+ return &RedisStore{
+ c: c,
+ prefix: prefix,
+ sid: sid,
+ duration: dur,
+ data: kv,
+ }
+}
+
+// Set sets value to given key in session.
+func (s *RedisStore) Set(key, val any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data[key] = val
+ return nil
+}
+
+// Get gets value by given key in session.
+func (s *RedisStore) Get(key any) any {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ return s.data[key]
+}
+
+// Delete delete a key from session.
+func (s *RedisStore) Delete(key any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ delete(s.data, key)
+ return nil
+}
+
+// ID returns current session ID.
+func (s *RedisStore) ID() string {
+ return s.sid
+}
+
+// Release releases resource and save data to provider.
+func (s *RedisStore) Release() error {
+ // Skip encoding if the data is empty
+ if len(s.data) == 0 {
+ return nil
+ }
+
+ data, err := session.EncodeGob(s.data)
+ if err != nil {
+ return err
+ }
+
+ return s.c.Set(graceful.GetManager().HammerContext(), s.prefix+s.sid, string(data), s.duration).Err()
+}
+
+// Flush deletes all session data.
+func (s *RedisStore) Flush() error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data = make(map[any]any)
+ return nil
+}
+
+// RedisProvider represents a redis session provider implementation.
+type RedisProvider struct {
+ c nosql.RedisClient
+ duration time.Duration
+ prefix string
+}
+
+// Init initializes redis session provider.
+// configs: network=tcp,addr=:6379,password=macaron,db=0,pool_size=100,idle_timeout=180,prefix=session;
+func (p *RedisProvider) Init(maxlifetime int64, configs string) (err error) {
+ p.duration, err = time.ParseDuration(fmt.Sprintf("%ds", maxlifetime))
+ if err != nil {
+ return err
+ }
+
+ uri := nosql.ToRedisURI(configs)
+
+ for k, v := range uri.Query() {
+ if k == "prefix" {
+ p.prefix = v[0]
+ }
+ }
+
+ p.c = nosql.GetManager().GetRedisClient(uri.String())
+ return p.c.Ping(graceful.GetManager().ShutdownContext()).Err()
+}
+
+// Read returns raw session store by session ID.
+func (p *RedisProvider) Read(sid string) (session.RawStore, error) {
+ psid := p.prefix + sid
+ if !p.Exist(sid) {
+ if err := p.c.Set(graceful.GetManager().HammerContext(), psid, "", p.duration).Err(); err != nil {
+ return nil, err
+ }
+ }
+
+ var kv map[any]any
+ kvs, err := p.c.Get(graceful.GetManager().HammerContext(), psid).Result()
+ if err != nil {
+ return nil, err
+ }
+ if len(kvs) == 0 {
+ kv = make(map[any]any)
+ } else {
+ kv, err = session.DecodeGob([]byte(kvs))
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return NewRedisStore(p.c, p.prefix, sid, p.duration, kv), nil
+}
+
+// Exist returns true if session with given ID exists.
+func (p *RedisProvider) Exist(sid string) bool {
+ v, err := p.c.Exists(graceful.GetManager().HammerContext(), p.prefix+sid).Result()
+ return err == nil && v == 1
+}
+
+// Destroy deletes a session by session ID.
+func (p *RedisProvider) Destroy(sid string) error {
+ return p.c.Del(graceful.GetManager().HammerContext(), p.prefix+sid).Err()
+}
+
+// Regenerate regenerates a session store from old session ID to new one.
+func (p *RedisProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err error) {
+ poldsid := p.prefix + oldsid
+ psid := p.prefix + sid
+
+ if p.Exist(sid) {
+ return nil, fmt.Errorf("new sid '%s' already exists", sid)
+ } else if !p.Exist(oldsid) {
+ // Make a fake old session.
+ if err = p.c.Set(graceful.GetManager().HammerContext(), poldsid, "", p.duration).Err(); err != nil {
+ return nil, err
+ }
+ }
+
+ // do not use Rename here, because the old sid and new sid may be in different redis cluster slot.
+ kvs, err := p.c.Get(graceful.GetManager().HammerContext(), poldsid).Result()
+ if err != nil {
+ return nil, err
+ }
+
+ if err = p.c.Del(graceful.GetManager().HammerContext(), poldsid).Err(); err != nil {
+ return nil, err
+ }
+
+ if err = p.c.Set(graceful.GetManager().HammerContext(), psid, kvs, p.duration).Err(); err != nil {
+ return nil, err
+ }
+
+ var kv map[any]any
+ if len(kvs) == 0 {
+ kv = make(map[any]any)
+ } else {
+ kv, err = session.DecodeGob([]byte(kvs))
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return NewRedisStore(p.c, p.prefix, sid, p.duration, kv), nil
+}
+
+// Count counts and returns number of sessions.
+func (p *RedisProvider) Count() int {
+ size, err := p.c.DBSize(graceful.GetManager().HammerContext()).Result()
+ if err != nil {
+ return 0
+ }
+ return int(size)
+}
+
+// GC calls GC to clean expired sessions.
+func (*RedisProvider) GC() {}
+
+func init() {
+ session.Register("redis", &RedisProvider{})
+}
diff --git a/modules/session/store.go b/modules/session/store.go
new file mode 100644
index 0000000..baab263
--- /dev/null
+++ b/modules/session/store.go
@@ -0,0 +1,29 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package session
+
+import (
+ "net/http"
+
+ "code.forgejo.org/go-chi/session"
+)
+
+// Store represents a session store
+type Store interface {
+ Get(any) any
+ Set(any, any) error
+ Delete(any) error
+}
+
+// RegenerateSession regenerates the underlying session and returns the new store
+func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) {
+ for _, f := range BeforeRegenerateSession {
+ f(resp, req)
+ }
+ s, err := session.RegenerateSession(resp, req)
+ return s, err
+}
+
+// BeforeRegenerateSession is a list of functions that are called before a session is regenerated.
+var BeforeRegenerateSession []func(http.ResponseWriter, *http.Request)
diff --git a/modules/session/virtual.go b/modules/session/virtual.go
new file mode 100644
index 0000000..9cf3683
--- /dev/null
+++ b/modules/session/virtual.go
@@ -0,0 +1,198 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package session
+
+import (
+ "fmt"
+ "sync"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+
+ "code.forgejo.org/go-chi/session"
+ memcache "code.forgejo.org/go-chi/session/memcache"
+ mysql "code.forgejo.org/go-chi/session/mysql"
+ postgres "code.forgejo.org/go-chi/session/postgres"
+)
+
+// VirtualSessionProvider represents a shadowed session provider implementation.
+type VirtualSessionProvider struct {
+ lock sync.RWMutex
+ provider session.Provider
+}
+
+// Init initializes the cookie session provider with given root path.
+func (o *VirtualSessionProvider) Init(gclifetime int64, config string) error {
+ var opts session.Options
+ if err := json.Unmarshal([]byte(config), &opts); err != nil {
+ return err
+ }
+ // Note that these options are unprepared so we can't just use NewManager here.
+ // Nor can we access the provider map in session.
+ // So we will just have to do this by hand.
+ // This is only slightly more wrong than modules/setting/session.go:23
+ switch opts.Provider {
+ case "memory":
+ o.provider = &session.MemProvider{}
+ case "couchbase":
+ log.Warn("Couchbase as session provider is no longer supported, falling back to file as session provider")
+ fallthrough
+ case "file":
+ o.provider = &session.FileProvider{}
+ case "redis":
+ o.provider = &RedisProvider{}
+ case "db":
+ o.provider = &DBProvider{}
+ case "mysql":
+ o.provider = &mysql.MysqlProvider{}
+ case "postgres":
+ o.provider = &postgres.PostgresProvider{}
+ case "memcache":
+ o.provider = &memcache.MemcacheProvider{}
+ default:
+ return fmt.Errorf("VirtualSessionProvider: Unknown Provider: %s", opts.Provider)
+ }
+ return o.provider.Init(gclifetime, opts.ProviderConfig)
+}
+
+// Read returns raw session store by session ID.
+func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) {
+ o.lock.RLock()
+ defer o.lock.RUnlock()
+ if o.provider.Exist(sid) {
+ return o.provider.Read(sid)
+ }
+ kv := make(map[any]any)
+ kv["_old_uid"] = "0"
+ return NewVirtualStore(o, sid, kv), nil
+}
+
+// Exist returns true if session with given ID exists.
+func (o *VirtualSessionProvider) Exist(sid string) bool {
+ return true
+}
+
+// Destroy deletes a session by session ID.
+func (o *VirtualSessionProvider) Destroy(sid string) error {
+ o.lock.Lock()
+ defer o.lock.Unlock()
+ return o.provider.Destroy(sid)
+}
+
+// Regenerate regenerates a session store from old session ID to new one.
+func (o *VirtualSessionProvider) Regenerate(oldsid, sid string) (session.RawStore, error) {
+ o.lock.Lock()
+ defer o.lock.Unlock()
+ return o.provider.Regenerate(oldsid, sid)
+}
+
+// Count counts and returns number of sessions.
+func (o *VirtualSessionProvider) Count() int {
+ o.lock.RLock()
+ defer o.lock.RUnlock()
+ return o.provider.Count()
+}
+
+// GC calls GC to clean expired sessions.
+func (o *VirtualSessionProvider) GC() {
+ o.provider.GC()
+}
+
+func init() {
+ session.Register("VirtualSession", &VirtualSessionProvider{})
+}
+
+// VirtualStore represents a virtual session store implementation.
+type VirtualStore struct {
+ p *VirtualSessionProvider
+ sid string
+ lock sync.RWMutex
+ data map[any]any
+ released bool
+}
+
+// NewVirtualStore creates and returns a virtual session store.
+func NewVirtualStore(p *VirtualSessionProvider, sid string, kv map[any]any) *VirtualStore {
+ return &VirtualStore{
+ p: p,
+ sid: sid,
+ data: kv,
+ }
+}
+
+// Set sets value to given key in session.
+func (s *VirtualStore) Set(key, val any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data[key] = val
+ return nil
+}
+
+// Get gets value by given key in session.
+func (s *VirtualStore) Get(key any) any {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ return s.data[key]
+}
+
+// Delete delete a key from session.
+func (s *VirtualStore) Delete(key any) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ delete(s.data, key)
+ return nil
+}
+
+// ID returns current session ID.
+func (s *VirtualStore) ID() string {
+ return s.sid
+}
+
+// Release releases resource and save data to provider.
+func (s *VirtualStore) Release() error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ // Now need to lock the provider
+ s.p.lock.Lock()
+ defer s.p.lock.Unlock()
+ if oldUID, ok := s.data["_old_uid"]; (ok && (oldUID != "0" || len(s.data) > 1)) || (!ok && len(s.data) > 0) {
+ // Now ensure that we don't exist!
+ realProvider := s.p.provider
+
+ if !s.released && realProvider.Exist(s.sid) {
+ // This is an error!
+ return fmt.Errorf("new sid '%s' already exists", s.sid)
+ }
+ realStore, err := realProvider.Read(s.sid)
+ if err != nil {
+ return err
+ }
+ if err := realStore.Flush(); err != nil {
+ return err
+ }
+ for key, value := range s.data {
+ if err := realStore.Set(key, value); err != nil {
+ return err
+ }
+ }
+ err = realStore.Release()
+ if err == nil {
+ s.released = true
+ }
+ return err
+ }
+ return nil
+}
+
+// Flush deletes all session data.
+func (s *VirtualStore) Flush() error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.data = make(map[any]any)
+ return nil
+}
diff --git a/modules/setting/actions.go b/modules/setting/actions.go
new file mode 100644
index 0000000..8c1b57b
--- /dev/null
+++ b/modules/setting/actions.go
@@ -0,0 +1,106 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Actions settings
+var (
+ Actions = struct {
+ Enabled bool
+ LogStorage *Storage // how the created logs should be stored
+ LogRetentionDays int64 `ini:"LOG_RETENTION_DAYS"`
+ LogCompression logCompression `ini:"LOG_COMPRESSION"`
+ ArtifactStorage *Storage // how the created artifacts should be stored
+ ArtifactRetentionDays int64 `ini:"ARTIFACT_RETENTION_DAYS"`
+ DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
+ ZombieTaskTimeout time.Duration `ini:"ZOMBIE_TASK_TIMEOUT"`
+ EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"`
+ AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"`
+ SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"`
+ LimitDispatchInputs int64 `ini:"LIMIT_DISPATCH_INPUTS"`
+ }{
+ Enabled: true,
+ DefaultActionsURL: defaultActionsURLForgejo,
+ SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
+ LimitDispatchInputs: 10,
+ }
+)
+
+type defaultActionsURL string
+
+func (url defaultActionsURL) URL() string {
+ switch url {
+ case defaultActionsURLGitHub:
+ return "https://github.com"
+ case defaultActionsURLSelf:
+ return strings.TrimSuffix(AppURL, "/")
+ default:
+ return string(url)
+ }
+}
+
+const (
+ defaultActionsURLForgejo = "https://code.forgejo.org"
+ defaultActionsURLGitHub = "github" // https://github.com
+ defaultActionsURLSelf = "self" // the root URL of the self-hosted instance
+)
+
+type logCompression string
+
+func (c logCompression) IsValid() bool {
+ return c.IsNone() || c.IsZstd()
+}
+
+func (c logCompression) IsNone() bool {
+ return strings.ToLower(string(c)) == "none"
+}
+
+func (c logCompression) IsZstd() bool {
+ return c == "" || strings.ToLower(string(c)) == "zstd"
+}
+
+func loadActionsFrom(rootCfg ConfigProvider) error {
+ sec := rootCfg.Section("actions")
+ err := sec.MapTo(&Actions)
+ if err != nil {
+ return fmt.Errorf("failed to map Actions settings: %v", err)
+ }
+
+ // don't support to read configuration from [actions]
+ Actions.LogStorage, err = getStorage(rootCfg, "actions_log", "", nil)
+ if err != nil {
+ return err
+ }
+ // default to 1 year
+ if Actions.LogRetentionDays <= 0 {
+ Actions.LogRetentionDays = 365
+ }
+
+ actionsSec, _ := rootCfg.GetSection("actions.artifacts")
+
+ Actions.ArtifactStorage, err = getStorage(rootCfg, "actions_artifacts", "", actionsSec)
+ if err != nil {
+ return err
+ }
+
+ // default to 90 days in Github Actions
+ if Actions.ArtifactRetentionDays <= 0 {
+ Actions.ArtifactRetentionDays = 90
+ }
+
+ Actions.ZombieTaskTimeout = sec.Key("ZOMBIE_TASK_TIMEOUT").MustDuration(10 * time.Minute)
+ Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour)
+ Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour)
+
+ if !Actions.LogCompression.IsValid() {
+ return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
+ }
+
+ return nil
+}
diff --git a/modules/setting/actions_test.go b/modules/setting/actions_test.go
new file mode 100644
index 0000000..afd76d3
--- /dev/null
+++ b/modules/setting/actions_test.go
@@ -0,0 +1,157 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getStorageInheritNameSectionTypeForActions(t *testing.T) {
+ iniStr := `
+ [storage]
+ STORAGE_TYPE = minio
+ `
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "minio", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log/", Actions.LogStorage.MinioConfig.BasePath)
+ assert.EqualValues(t, "minio", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts/", Actions.ArtifactStorage.MinioConfig.BasePath)
+
+ iniStr = `
+[storage.actions_log]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "minio", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log/", Actions.LogStorage.MinioConfig.BasePath)
+ assert.EqualValues(t, "local", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts", filepath.Base(Actions.ArtifactStorage.Path))
+
+ iniStr = `
+[storage.actions_log]
+STORAGE_TYPE = my_storage
+
+[storage.my_storage]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "minio", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log/", Actions.LogStorage.MinioConfig.BasePath)
+ assert.EqualValues(t, "local", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts", filepath.Base(Actions.ArtifactStorage.Path))
+
+ iniStr = `
+[storage.actions_artifacts]
+STORAGE_TYPE = my_storage
+
+[storage.my_storage]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "local", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log", filepath.Base(Actions.LogStorage.Path))
+ assert.EqualValues(t, "minio", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts/", Actions.ArtifactStorage.MinioConfig.BasePath)
+
+ iniStr = `
+[storage.actions_artifacts]
+STORAGE_TYPE = my_storage
+
+[storage.my_storage]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "local", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log", filepath.Base(Actions.LogStorage.Path))
+ assert.EqualValues(t, "minio", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts/", Actions.ArtifactStorage.MinioConfig.BasePath)
+
+ iniStr = ``
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, "local", Actions.LogStorage.Type)
+ assert.EqualValues(t, "actions_log", filepath.Base(Actions.LogStorage.Path))
+ assert.EqualValues(t, "local", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "actions_artifacts", filepath.Base(Actions.ArtifactStorage.Path))
+}
+
+func Test_getDefaultActionsURLForActions(t *testing.T) {
+ oldActions := Actions
+ oldAppURL := AppURL
+ defer func() {
+ Actions = oldActions
+ AppURL = oldAppURL
+ }()
+
+ AppURL = "http://test_get_default_actions_url_for_actions:3000/"
+
+ tests := []struct {
+ name string
+ iniStr string
+ wantURL string
+ }{
+ {
+ name: "default",
+ iniStr: `
+[actions]
+`,
+ wantURL: "https://code.forgejo.org",
+ },
+ {
+ name: "github",
+ iniStr: `
+[actions]
+DEFAULT_ACTIONS_URL = github
+`,
+ wantURL: "https://github.com",
+ },
+ {
+ name: "self",
+ iniStr: `
+[actions]
+DEFAULT_ACTIONS_URL = self
+`,
+ wantURL: "http://test_get_default_actions_url_for_actions:3000",
+ },
+ {
+ name: "custom urls",
+ iniStr: `
+[actions]
+DEFAULT_ACTIONS_URL = https://example.com
+`,
+ wantURL: "https://example.com",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(tt.iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadActionsFrom(cfg))
+
+ assert.EqualValues(t, tt.wantURL, Actions.DefaultActionsURL.URL())
+ })
+ }
+}
diff --git a/modules/setting/admin.go b/modules/setting/admin.go
new file mode 100644
index 0000000..eed3aa2
--- /dev/null
+++ b/modules/setting/admin.go
@@ -0,0 +1,32 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/container"
+)
+
+// Admin settings
+var Admin struct {
+ DisableRegularOrgCreation bool
+ DefaultEmailNotification string
+ SendNotificationEmailOnNewUser bool
+ UserDisabledFeatures container.Set[string]
+ ExternalUserDisableFeatures container.Set[string]
+}
+
+func loadAdminFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("admin")
+ Admin.DisableRegularOrgCreation = sec.Key("DISABLE_REGULAR_ORG_CREATION").MustBool(false)
+ Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled")
+ Admin.SendNotificationEmailOnNewUser = sec.Key("SEND_NOTIFICATION_EMAIL_ON_NEW_USER").MustBool(false)
+ Admin.UserDisabledFeatures = container.SetOf(sec.Key("USER_DISABLED_FEATURES").Strings(",")...)
+ Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...)
+}
+
+const (
+ UserFeatureDeletion = "deletion"
+ UserFeatureManageSSHKeys = "manage_ssh_keys"
+ UserFeatureManageGPGKeys = "manage_gpg_keys"
+)
diff --git a/modules/setting/admin_test.go b/modules/setting/admin_test.go
new file mode 100644
index 0000000..0c6c24b
--- /dev/null
+++ b/modules/setting/admin_test.go
@@ -0,0 +1,33 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/container"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_loadAdminFrom(t *testing.T) {
+ iniStr := `
+ [admin]
+ DISABLE_REGULAR_ORG_CREATION = true
+ DEFAULT_EMAIL_NOTIFICATIONS = z
+ SEND_NOTIFICATION_EMAIL_ON_NEW_USER = true
+ USER_DISABLED_FEATURES = a,b
+ EXTERNAL_USER_DISABLE_FEATURES = x,y
+ `
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ loadAdminFrom(cfg)
+
+ assert.True(t, Admin.DisableRegularOrgCreation)
+ assert.EqualValues(t, "z", Admin.DefaultEmailNotification)
+ assert.True(t, Admin.SendNotificationEmailOnNewUser)
+ assert.EqualValues(t, container.SetOf("a", "b"), Admin.UserDisabledFeatures)
+ assert.EqualValues(t, container.SetOf("x", "y"), Admin.ExternalUserDisableFeatures)
+}
diff --git a/modules/setting/api.go b/modules/setting/api.go
new file mode 100644
index 0000000..c36f05c
--- /dev/null
+++ b/modules/setting/api.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/url"
+ "path"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// API settings
+var API = struct {
+ EnableSwagger bool
+ SwaggerURL string
+ MaxResponseItems int
+ DefaultPagingNum int
+ DefaultGitTreesPerPage int
+ DefaultMaxBlobSize int64
+}{
+ EnableSwagger: true,
+ SwaggerURL: "",
+ MaxResponseItems: 50,
+ DefaultPagingNum: 30,
+ DefaultGitTreesPerPage: 1000,
+ DefaultMaxBlobSize: 10485760,
+}
+
+func loadAPIFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "api", &API)
+
+ defaultAppURL := string(Protocol) + "://" + Domain + ":" + HTTPPort
+ u, err := url.Parse(rootCfg.Section("server").Key("ROOT_URL").MustString(defaultAppURL))
+ if err != nil {
+ log.Fatal("Invalid ROOT_URL '%s': %s", AppURL, err)
+ }
+ u.Path = path.Join(u.Path, "api", "swagger")
+ API.SwaggerURL = u.String()
+}
diff --git a/modules/setting/asset_dynamic.go b/modules/setting/asset_dynamic.go
new file mode 100644
index 0000000..2eb2883
--- /dev/null
+++ b/modules/setting/asset_dynamic.go
@@ -0,0 +1,8 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package setting
+
+const HasBuiltinBindata = false
diff --git a/modules/setting/asset_static.go b/modules/setting/asset_static.go
new file mode 100644
index 0000000..889fca9
--- /dev/null
+++ b/modules/setting/asset_static.go
@@ -0,0 +1,8 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package setting
+
+const HasBuiltinBindata = true
diff --git a/modules/setting/attachment.go b/modules/setting/attachment.go
new file mode 100644
index 0000000..4255ac9
--- /dev/null
+++ b/modules/setting/attachment.go
@@ -0,0 +1,35 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Attachment settings
+var Attachment = struct {
+ Storage *Storage
+ AllowedTypes string
+ MaxSize int64
+ MaxFiles int
+ Enabled bool
+}{
+ Storage: &Storage{},
+ AllowedTypes: ".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip",
+ MaxSize: 2048,
+ MaxFiles: 5,
+ Enabled: true,
+}
+
+func loadAttachmentFrom(rootCfg ConfigProvider) (err error) {
+ sec, _ := rootCfg.GetSection("attachment")
+ if sec == nil {
+ Attachment.Storage, err = getStorage(rootCfg, "attachments", "", nil)
+ return err
+ }
+
+ Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip")
+ Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(2048)
+ Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5)
+ Attachment.Enabled = sec.Key("ENABLED").MustBool(true)
+
+ Attachment.Storage, err = getStorage(rootCfg, "attachments", "", sec)
+ return err
+}
diff --git a/modules/setting/attachment_test.go b/modules/setting/attachment_test.go
new file mode 100644
index 0000000..f8085c1
--- /dev/null
+++ b/modules/setting/attachment_test.go
@@ -0,0 +1,134 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getStorageCustomType(t *testing.T) {
+ iniStr := `
+[attachment]
+STORAGE_TYPE = my_minio
+MINIO_BUCKET = gitea-attachment
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = my_minio:9000
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+
+ assert.EqualValues(t, "minio", Attachment.Storage.Type)
+ assert.EqualValues(t, "my_minio:9000", Attachment.Storage.MinioConfig.Endpoint)
+ assert.EqualValues(t, "gitea-attachment", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageTypeSectionOverridesStorageSection(t *testing.T) {
+ iniStr := `
+[attachment]
+STORAGE_TYPE = minio
+
+[storage.minio]
+MINIO_BUCKET = gitea-minio
+
+[storage]
+MINIO_BUCKET = gitea
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+
+ assert.EqualValues(t, "minio", Attachment.Storage.Type)
+ assert.EqualValues(t, "gitea-minio", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageSpecificOverridesStorage(t *testing.T) {
+ iniStr := `
+[attachment]
+STORAGE_TYPE = minio
+MINIO_BUCKET = gitea-attachment
+
+[storage.attachments]
+MINIO_BUCKET = gitea
+
+[storage]
+STORAGE_TYPE = local
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+
+ assert.EqualValues(t, "minio", Attachment.Storage.Type)
+ assert.EqualValues(t, "gitea-attachment", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageGetDefaults(t *testing.T) {
+ cfg, err := NewConfigProviderFromData("")
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+
+ // default storage is local, so bucket is empty
+ assert.EqualValues(t, "", Attachment.Storage.MinioConfig.Bucket)
+}
+
+func Test_getStorageInheritNameSectionType(t *testing.T) {
+ iniStr := `
+[storage.attachments]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+
+ assert.EqualValues(t, "minio", Attachment.Storage.Type)
+}
+
+func Test_AttachmentStorage(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[storage]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+ storage := Attachment.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+}
+
+func Test_AttachmentStorage1(t *testing.T) {
+ iniStr := `
+[storage]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+ assert.EqualValues(t, "minio", Attachment.Storage.Type)
+ assert.EqualValues(t, "gitea", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+}
diff --git a/modules/setting/badges.go b/modules/setting/badges.go
new file mode 100644
index 0000000..e0c1cb5
--- /dev/null
+++ b/modules/setting/badges.go
@@ -0,0 +1,24 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "text/template"
+)
+
+// Badges settings
+var Badges = struct {
+ Enabled bool `ini:"ENABLED"`
+ GeneratorURLTemplate string `ini:"GENERATOR_URL_TEMPLATE"`
+ GeneratorURLTemplateTemplate *template.Template `ini:"-"`
+}{
+ Enabled: true,
+ GeneratorURLTemplate: "https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}",
+}
+
+func loadBadgesFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "badges", &Badges)
+
+ Badges.GeneratorURLTemplateTemplate = template.Must(template.New("").Parse(Badges.GeneratorURLTemplate))
+}
diff --git a/modules/setting/cache.go b/modules/setting/cache.go
new file mode 100644
index 0000000..bfa6ca0
--- /dev/null
+++ b/modules/setting/cache.go
@@ -0,0 +1,85 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Cache represents cache settings
+type Cache struct {
+ Adapter string
+ Interval int
+ Conn string
+ TTL time.Duration `ini:"ITEM_TTL"`
+}
+
+// CacheService the global cache
+var CacheService = struct {
+ Cache `ini:"cache"`
+
+ LastCommit struct {
+ TTL time.Duration `ini:"ITEM_TTL"`
+ CommitsCount int64
+ } `ini:"cache.last_commit"`
+}{
+ Cache: Cache{
+ Adapter: "memory",
+ Interval: 60,
+ TTL: 16 * time.Hour,
+ },
+ LastCommit: struct {
+ TTL time.Duration `ini:"ITEM_TTL"`
+ CommitsCount int64
+ }{
+ TTL: 8760 * time.Hour,
+ CommitsCount: 1000,
+ },
+}
+
+// MemcacheMaxTTL represents the maximum memcache TTL
+const MemcacheMaxTTL = 30 * 24 * time.Hour
+
+func loadCacheFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("cache")
+ if err := sec.MapTo(&CacheService); err != nil {
+ log.Fatal("Failed to map Cache settings: %v", err)
+ }
+
+ CacheService.Adapter = sec.Key("ADAPTER").In("memory", []string{"memory", "redis", "memcache", "twoqueue"})
+ switch CacheService.Adapter {
+ case "memory":
+ case "redis", "memcache":
+ CacheService.Conn = strings.Trim(sec.Key("HOST").String(), "\" ")
+ case "twoqueue":
+ CacheService.Conn = strings.TrimSpace(sec.Key("HOST").String())
+ if CacheService.Conn == "" {
+ CacheService.Conn = "50000"
+ }
+ default:
+ log.Fatal("Unknown cache adapter: %s", CacheService.Adapter)
+ }
+
+ sec = rootCfg.Section("cache.last_commit")
+ CacheService.LastCommit.CommitsCount = sec.Key("COMMITS_COUNT").MustInt64(1000)
+}
+
+// TTLSeconds returns the TTLSeconds or unix timestamp for memcache
+func (c Cache) TTLSeconds() int64 {
+ if c.Adapter == "memcache" && c.TTL > MemcacheMaxTTL {
+ return time.Now().Add(c.TTL).Unix()
+ }
+ return int64(c.TTL.Seconds())
+}
+
+// LastCommitCacheTTLSeconds returns the TTLSeconds or unix timestamp for memcache
+func LastCommitCacheTTLSeconds() int64 {
+ if CacheService.Adapter == "memcache" && CacheService.LastCommit.TTL > MemcacheMaxTTL {
+ return time.Now().Add(CacheService.LastCommit.TTL).Unix()
+ }
+ return int64(CacheService.LastCommit.TTL.Seconds())
+}
diff --git a/modules/setting/camo.go b/modules/setting/camo.go
new file mode 100644
index 0000000..608ecf8
--- /dev/null
+++ b/modules/setting/camo.go
@@ -0,0 +1,32 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "strconv"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var Camo = struct {
+ Enabled bool
+ ServerURL string `ini:"SERVER_URL"`
+ HMACKey string `ini:"HMAC_KEY"`
+ Always bool
+}{}
+
+func loadCamoFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "camo", &Camo)
+ if Camo.Enabled {
+ oldValue := rootCfg.Section("camo").Key("ALLWAYS").MustString("")
+ if oldValue != "" {
+ log.Warn("camo.ALLWAYS is deprecated, use camo.ALWAYS instead")
+ Camo.Always, _ = strconv.ParseBool(oldValue)
+ }
+
+ if Camo.ServerURL == "" || Camo.HMACKey == "" {
+ log.Fatal(`Camo settings require "SERVER_URL" and HMAC_KEY`)
+ }
+ }
+}
diff --git a/modules/setting/config.go b/modules/setting/config.go
new file mode 100644
index 0000000..0355857
--- /dev/null
+++ b/modules/setting/config.go
@@ -0,0 +1,98 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting/config"
+)
+
+type PictureStruct struct {
+ DisableGravatar *config.Value[bool]
+ EnableFederatedAvatar *config.Value[bool]
+}
+
+type OpenWithEditorApp struct {
+ DisplayName string
+ OpenURL string
+}
+
+type OpenWithEditorAppsType []OpenWithEditorApp
+
+func (t OpenWithEditorAppsType) ToTextareaString() string {
+ ret := ""
+ for _, app := range t {
+ ret += app.DisplayName + " = " + app.OpenURL + "\n"
+ }
+ return ret
+}
+
+func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
+ return OpenWithEditorAppsType{
+ {
+ DisplayName: "VS Code",
+ OpenURL: "vscode://vscode.git/clone?url={url}",
+ },
+ {
+ DisplayName: "VSCodium",
+ OpenURL: "vscodium://vscode.git/clone?url={url}",
+ },
+ {
+ DisplayName: "Intellij IDEA",
+ OpenURL: "jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo={url}",
+ },
+ }
+}
+
+type RepositoryStruct struct {
+ OpenWithEditorApps *config.Value[OpenWithEditorAppsType]
+}
+
+type ConfigStruct struct {
+ Picture *PictureStruct
+ Repository *RepositoryStruct
+}
+
+var (
+ defaultConfig *ConfigStruct
+ defaultConfigOnce sync.Once
+)
+
+func initDefaultConfig() {
+ config.SetCfgSecKeyGetter(&cfgSecKeyGetter{})
+ defaultConfig = &ConfigStruct{
+ Picture: &PictureStruct{
+ DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
+ EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
+ },
+ Repository: &RepositoryStruct{
+ OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
+ },
+ }
+}
+
+func Config() *ConfigStruct {
+ defaultConfigOnce.Do(initDefaultConfig)
+ return defaultConfig
+}
+
+type cfgSecKeyGetter struct{}
+
+func (c cfgSecKeyGetter) GetValue(sec, key string) (v string, has bool) {
+ if key == "" {
+ return "", false
+ }
+ cfgSec, err := CfgProvider.GetSection(sec)
+ if err != nil {
+ log.Error("Unable to get config section: %q", sec)
+ return "", false
+ }
+ cfgKey := ConfigSectionKey(cfgSec, key)
+ if cfgKey == nil {
+ return "", false
+ }
+ return cfgKey.Value(), true
+}
diff --git a/modules/setting/config/getter.go b/modules/setting/config/getter.go
new file mode 100644
index 0000000..99f9a47
--- /dev/null
+++ b/modules/setting/config/getter.go
@@ -0,0 +1,49 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "context"
+ "sync"
+)
+
+var getterMu sync.RWMutex
+
+type CfgSecKeyGetter interface {
+ GetValue(sec, key string) (v string, has bool)
+}
+
+var cfgSecKeyGetterInternal CfgSecKeyGetter
+
+func SetCfgSecKeyGetter(p CfgSecKeyGetter) {
+ getterMu.Lock()
+ cfgSecKeyGetterInternal = p
+ getterMu.Unlock()
+}
+
+func GetCfgSecKeyGetter() CfgSecKeyGetter {
+ getterMu.RLock()
+ defer getterMu.RUnlock()
+ return cfgSecKeyGetterInternal
+}
+
+type DynKeyGetter interface {
+ GetValue(ctx context.Context, key string) (v string, has bool)
+ GetRevision(ctx context.Context) int
+ InvalidateCache()
+}
+
+var dynKeyGetterInternal DynKeyGetter
+
+func SetDynGetter(p DynKeyGetter) {
+ getterMu.Lock()
+ dynKeyGetterInternal = p
+ getterMu.Unlock()
+}
+
+func GetDynGetter() DynKeyGetter {
+ getterMu.RLock()
+ defer getterMu.RUnlock()
+ return dynKeyGetterInternal
+}
diff --git a/modules/setting/config/value.go b/modules/setting/config/value.go
new file mode 100644
index 0000000..f0ec120
--- /dev/null
+++ b/modules/setting/config/value.go
@@ -0,0 +1,94 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "context"
+ "sync"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type CfgSecKey struct {
+ Sec, Key string
+}
+
+type Value[T any] struct {
+ mu sync.RWMutex
+
+ cfgSecKey CfgSecKey
+ dynKey string
+
+ def, value T
+ revision int
+}
+
+func (value *Value[T]) parse(key, valStr string) (v T) {
+ v = value.def
+ if valStr != "" {
+ if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
+ log.Error("Unable to unmarshal json config for key %q, err: %v", key, err)
+ }
+ }
+ return v
+}
+
+func (value *Value[T]) Value(ctx context.Context) (v T) {
+ dg := GetDynGetter()
+ if dg == nil {
+ // this is an edge case: the database is not initialized but the system setting is going to be used
+ // it should panic to avoid inconsistent config values (from config / system setting) and fix the code
+ panic("no config dyn value getter")
+ }
+
+ rev := dg.GetRevision(ctx)
+
+ // if the revision in database doesn't change, use the last value
+ value.mu.RLock()
+ if rev == value.revision {
+ v = value.value
+ value.mu.RUnlock()
+ return v
+ }
+ value.mu.RUnlock()
+
+ // try to parse the config and cache it
+ var valStr *string
+ if dynVal, has := dg.GetValue(ctx, value.dynKey); has {
+ valStr = &dynVal
+ } else if cfgVal, has := GetCfgSecKeyGetter().GetValue(value.cfgSecKey.Sec, value.cfgSecKey.Key); has {
+ valStr = &cfgVal
+ }
+ if valStr == nil {
+ v = value.def
+ } else {
+ v = value.parse(value.dynKey, *valStr)
+ }
+
+ value.mu.Lock()
+ value.value = v
+ value.revision = rev
+ value.mu.Unlock()
+ return v
+}
+
+func (value *Value[T]) DynKey() string {
+ return value.dynKey
+}
+
+func (value *Value[T]) WithDefault(def T) *Value[T] {
+ value.def = def
+ return value
+}
+
+func (value *Value[T]) WithFileConfig(cfgSecKey CfgSecKey) *Value[T] {
+ value.cfgSecKey = cfgSecKey
+ return value
+}
+
+func ValueJSON[T any](dynKey string) *Value[T] {
+ return &Value[T]{dynKey: dynKey}
+}
diff --git a/modules/setting/config_env.go b/modules/setting/config_env.go
new file mode 100644
index 0000000..fa0100d
--- /dev/null
+++ b/modules/setting/config_env.go
@@ -0,0 +1,170 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "bytes"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+const (
+ EnvConfigKeyPrefixGitea = "^(FORGEJO|GITEA)__"
+ EnvConfigKeySuffixFile = "__FILE"
+)
+
+const escapeRegexpString = "_0[xX](([0-9a-fA-F][0-9a-fA-F])+)_"
+
+var escapeRegex = regexp.MustCompile(escapeRegexpString)
+
+func CollectEnvConfigKeys() (keys []string) {
+ for _, env := range os.Environ() {
+ if strings.HasPrefix(env, EnvConfigKeyPrefixGitea) {
+ k, _, _ := strings.Cut(env, "=")
+ keys = append(keys, k)
+ }
+ }
+ return keys
+}
+
+func ClearEnvConfigKeys() {
+ for _, k := range CollectEnvConfigKeys() {
+ _ = os.Unsetenv(k)
+ }
+}
+
+// decodeEnvSectionKey will decode a portable string encoded Section__Key pair
+// Portable strings are considered to be of the form [A-Z0-9_]*
+// We will encode a disallowed value as the UTF8 byte string preceded by _0X and
+// followed by _. E.g. _0X2C_ for a '-' and _0X2E_ for '.'
+// Section and Key are separated by a plain '__'.
+// The entire section can be encoded as a UTF8 byte string
+func decodeEnvSectionKey(encoded string) (ok bool, section, key string) {
+ inKey := false
+ last := 0
+ escapeStringIndices := escapeRegex.FindAllStringIndex(encoded, -1)
+ for _, unescapeIdx := range escapeStringIndices {
+ preceding := encoded[last:unescapeIdx[0]]
+ if !inKey {
+ if splitter := strings.Index(preceding, "__"); splitter > -1 {
+ section += preceding[:splitter]
+ inKey = true
+ key += preceding[splitter+2:]
+ } else {
+ section += preceding
+ }
+ } else {
+ key += preceding
+ }
+ toDecode := encoded[unescapeIdx[0]+3 : unescapeIdx[1]-1]
+ decodedBytes := make([]byte, len(toDecode)/2)
+ for i := 0; i < len(toDecode)/2; i++ {
+ // Can ignore error here as we know these should be hexadecimal from the regexp
+ byteInt, _ := strconv.ParseInt(toDecode[2*i:2*i+2], 16, 0)
+ decodedBytes[i] = byte(byteInt)
+ }
+ if inKey {
+ key += string(decodedBytes)
+ } else {
+ section += string(decodedBytes)
+ }
+ last = unescapeIdx[1]
+ }
+ remaining := encoded[last:]
+ if !inKey {
+ if splitter := strings.Index(remaining, "__"); splitter > -1 {
+ section += remaining[:splitter]
+ key += remaining[splitter+2:]
+ } else {
+ section += remaining
+ }
+ } else {
+ key += remaining
+ }
+ section = strings.ToLower(section)
+ ok = key != ""
+ if !ok {
+ section = ""
+ key = ""
+ }
+ return ok, section, key
+}
+
+// decodeEnvironmentKey decode the environment key to section and key
+// The environment key is in the form of GITEA__SECTION__KEY or GITEA__SECTION__KEY__FILE
+func decodeEnvironmentKey(prefixRegexp *regexp.Regexp, suffixFile, envKey string) (ok bool, section, key string, useFileValue bool) { //nolint:unparam
+ if strings.HasSuffix(envKey, suffixFile) {
+ useFileValue = true
+ envKey = envKey[:len(envKey)-len(suffixFile)]
+ }
+ loc := prefixRegexp.FindStringIndex(envKey)
+ if loc == nil {
+ return false, "", "", false
+ }
+ ok, section, key = decodeEnvSectionKey(envKey[loc[1]:])
+ return ok, section, key, useFileValue
+}
+
+func EnvironmentToConfig(cfg ConfigProvider, envs []string) (changed bool) {
+ prefixRegexp := regexp.MustCompile(EnvConfigKeyPrefixGitea)
+ for _, kv := range envs {
+ idx := strings.IndexByte(kv, '=')
+ if idx < 0 {
+ continue
+ }
+
+ // parse the environment variable to config section name and key name
+ envKey := kv[:idx]
+ envValue := kv[idx+1:]
+ ok, sectionName, keyName, useFileValue := decodeEnvironmentKey(prefixRegexp, EnvConfigKeySuffixFile, envKey)
+ if !ok {
+ continue
+ }
+
+ // use environment value as config value, or read the file content as value if the key indicates a file
+ keyValue := envValue
+ if useFileValue {
+ fileContent, err := os.ReadFile(envValue)
+ if err != nil {
+ log.Error("Error reading file for %s : %v", envKey, envValue, err)
+ continue
+ }
+ if bytes.HasSuffix(fileContent, []byte("\r\n")) {
+ fileContent = fileContent[:len(fileContent)-2]
+ } else if bytes.HasSuffix(fileContent, []byte("\n")) {
+ fileContent = fileContent[:len(fileContent)-1]
+ }
+ keyValue = string(fileContent)
+ }
+
+ // try to set the config value if necessary
+ section, err := cfg.GetSection(sectionName)
+ if err != nil {
+ section, err = cfg.NewSection(sectionName)
+ if err != nil {
+ log.Error("Error creating section: %s : %v", sectionName, err)
+ continue
+ }
+ }
+ key := ConfigSectionKey(section, keyName)
+ if key == nil {
+ changed = true
+ key, err = section.NewKey(keyName, keyValue)
+ if err != nil {
+ log.Error("Error creating key: %s in section: %s with value: %s : %v", keyName, sectionName, keyValue, err)
+ continue
+ }
+ }
+ oldValue := key.Value()
+ if !changed && oldValue != keyValue {
+ changed = true
+ }
+ key.SetValue(keyValue)
+ }
+ return changed
+}
diff --git a/modules/setting/config_env_test.go b/modules/setting/config_env_test.go
new file mode 100644
index 0000000..bec3e58
--- /dev/null
+++ b/modules/setting/config_env_test.go
@@ -0,0 +1,151 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os"
+ "regexp"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodeEnvSectionKey(t *testing.T) {
+ ok, section, key := decodeEnvSectionKey("SEC__KEY")
+ assert.True(t, ok)
+ assert.Equal(t, "sec", section)
+ assert.Equal(t, "KEY", key)
+
+ ok, section, key = decodeEnvSectionKey("sec__key")
+ assert.True(t, ok)
+ assert.Equal(t, "sec", section)
+ assert.Equal(t, "key", key)
+
+ ok, section, key = decodeEnvSectionKey("LOG_0x2E_CONSOLE__STDERR")
+ assert.True(t, ok)
+ assert.Equal(t, "log.console", section)
+ assert.Equal(t, "STDERR", key)
+
+ ok, section, key = decodeEnvSectionKey("SEC")
+ assert.False(t, ok)
+ assert.Equal(t, "", section)
+ assert.Equal(t, "", key)
+}
+
+func TestDecodeEnvironmentKey(t *testing.T) {
+ prefix := regexp.MustCompile(EnvConfigKeyPrefixGitea)
+ suffix := "__FILE"
+
+ ok, section, key, file := decodeEnvironmentKey(prefix, suffix, "SEC__KEY")
+ assert.False(t, ok)
+ assert.Equal(t, "", section)
+ assert.Equal(t, "", key)
+ assert.False(t, file)
+
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC")
+ assert.False(t, ok)
+ assert.Equal(t, "", section)
+ assert.Equal(t, "", key)
+ assert.False(t, file)
+
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA____KEY")
+ assert.True(t, ok)
+ assert.Equal(t, "", section)
+ assert.Equal(t, "KEY", key)
+ assert.False(t, file)
+
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__KEY")
+ assert.True(t, ok)
+ assert.Equal(t, "sec", section)
+ assert.Equal(t, "KEY", key)
+ assert.False(t, file)
+
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "FORGEJO__SEC__KEY")
+ assert.True(t, ok)
+ assert.Equal(t, "sec", section)
+ assert.Equal(t, "KEY", key)
+ assert.False(t, file)
+
+ // with "__FILE" suffix, it doesn't support to write "[sec].FILE" to config (no such key FILE is used in Gitea)
+ // but it could be fixed in the future by adding a new suffix like "__VALUE" (no such key VALUE is used in Gitea either)
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__FILE")
+ assert.False(t, ok)
+ assert.Equal(t, "", section)
+ assert.Equal(t, "", key)
+ assert.True(t, file)
+
+ ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__KEY__FILE")
+ assert.True(t, ok)
+ assert.Equal(t, "sec", section)
+ assert.Equal(t, "KEY", key)
+ assert.True(t, file)
+}
+
+func TestEnvironmentToConfig(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData("")
+
+ changed := EnvironmentToConfig(cfg, nil)
+ assert.False(t, changed)
+
+ cfg, err := NewConfigProviderFromData(`
+[sec]
+key = old
+`)
+ require.NoError(t, err)
+
+ changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key=new"})
+ assert.True(t, changed)
+ assert.Equal(t, "new", cfg.Section("sec").Key("key").String())
+
+ changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key=new"})
+ assert.False(t, changed)
+
+ tmpFile := t.TempDir() + "/the-file"
+ _ = os.WriteFile(tmpFile, []byte("value-from-file"), 0o644)
+ changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile})
+ assert.True(t, changed)
+ assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String())
+
+ cfg, _ = NewConfigProviderFromData("")
+ _ = os.WriteFile(tmpFile, []byte("value-from-file\n"), 0o644)
+ EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile})
+ assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String())
+
+ cfg, _ = NewConfigProviderFromData("")
+ _ = os.WriteFile(tmpFile, []byte("value-from-file\r\n"), 0o644)
+ EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile})
+ assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String())
+
+ cfg, _ = NewConfigProviderFromData("")
+ _ = os.WriteFile(tmpFile, []byte("value-from-file\n\n"), 0o644)
+ EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile})
+ assert.Equal(t, "value-from-file\n", cfg.Section("sec").Key("key").String())
+}
+
+func TestEnvironmentToConfigSubSecKey(t *testing.T) {
+ // the INI package has a quirk: by default, the keys are inherited.
+ // when maintaining the keys, the newly added sub key should not be affected by the parent key.
+ cfg, err := NewConfigProviderFromData(`
+[sec]
+key = some
+`)
+ require.NoError(t, err)
+
+ changed := EnvironmentToConfig(cfg, []string{"GITEA__sec_0X2E_sub__key=some"})
+ assert.True(t, changed)
+
+ tmpFile := t.TempDir() + "/test-sub-sec-key.ini"
+ defer os.Remove(tmpFile)
+ err = cfg.SaveTo(tmpFile)
+ require.NoError(t, err)
+ bs, err := os.ReadFile(tmpFile)
+ require.NoError(t, err)
+ assert.Equal(t, `[sec]
+key = some
+
+[sec.sub]
+key = some
+`, string(bs))
+}
diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go
new file mode 100644
index 0000000..12cf36a
--- /dev/null
+++ b/modules/setting/config_provider.go
@@ -0,0 +1,360 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+
+ "gopkg.in/ini.v1" //nolint:depguard
+)
+
+type ConfigKey interface {
+ Name() string
+ Value() string
+ SetValue(v string)
+
+ In(defaultVal string, candidates []string) string
+ String() string
+ Strings(delim string) []string
+
+ MustString(defaultVal string) string
+ MustBool(defaultVal ...bool) bool
+ MustInt(defaultVal ...int) int
+ MustInt64(defaultVal ...int64) int64
+ MustDuration(defaultVal ...time.Duration) time.Duration
+}
+
+type ConfigSection interface {
+ Name() string
+ MapTo(any) error
+ HasKey(key string) bool
+ NewKey(name, value string) (ConfigKey, error)
+ Key(key string) ConfigKey
+ Keys() []ConfigKey
+ ChildSections() []ConfigSection
+}
+
+// ConfigProvider represents a config provider
+type ConfigProvider interface {
+ Section(section string) ConfigSection
+ Sections() []ConfigSection
+ NewSection(name string) (ConfigSection, error)
+ GetSection(name string) (ConfigSection, error)
+ Save() error
+ SaveTo(filename string) error
+
+ GetFile() string
+ DisableSaving()
+ PrepareSaving() (ConfigProvider, error)
+ IsLoadedFromEmpty() bool
+}
+
+type iniConfigProvider struct {
+ file string
+ ini *ini.File
+
+ disableSaving bool // disable the "Save" method because the config options could be polluted
+ loadedFromEmpty bool // whether the file has not existed previously
+}
+
+type iniConfigSection struct {
+ sec *ini.Section
+}
+
+var (
+ _ ConfigProvider = (*iniConfigProvider)(nil)
+ _ ConfigSection = (*iniConfigSection)(nil)
+ _ ConfigKey = (*ini.Key)(nil)
+)
+
+// ConfigSectionKey only searches the keys in the given section, but it is O(n).
+// ini package has a special behavior: with "[sec] a=1" and an empty "[sec.sub]",
+// then in "[sec.sub]", Key()/HasKey() can always see "a=1" because it always tries parent sections.
+// It returns nil if the key doesn't exist.
+func ConfigSectionKey(sec ConfigSection, key string) ConfigKey {
+ if sec == nil {
+ return nil
+ }
+ for _, k := range sec.Keys() {
+ if k.Name() == key {
+ return k
+ }
+ }
+ return nil
+}
+
+func ConfigSectionKeyString(sec ConfigSection, key string, def ...string) string {
+ k := ConfigSectionKey(sec, key)
+ if k != nil && k.String() != "" {
+ return k.String()
+ }
+ if len(def) > 0 {
+ return def[0]
+ }
+ return ""
+}
+
+func ConfigSectionKeyBool(sec ConfigSection, key string, def ...bool) bool {
+ k := ConfigSectionKey(sec, key)
+ if k != nil && k.String() != "" {
+ b, _ := strconv.ParseBool(k.String())
+ return b
+ }
+ if len(def) > 0 {
+ return def[0]
+ }
+ return false
+}
+
+// ConfigInheritedKey works like ini.Section.Key(), but it always returns a new key instance, it is O(n) because NewKey is O(n)
+// and the returned key is safe to be used with "MustXxx", it doesn't change the parent's values.
+// Otherwise, ini.Section.Key().MustXxx would pollute the parent section's keys.
+// It never returns nil.
+func ConfigInheritedKey(sec ConfigSection, key string) ConfigKey {
+ k := sec.Key(key)
+ if k != nil && k.String() != "" {
+ newKey, _ := sec.NewKey(k.Name(), k.String())
+ return newKey
+ }
+ newKey, _ := sec.NewKey(key, "")
+ return newKey
+}
+
+func ConfigInheritedKeyString(sec ConfigSection, key string, def ...string) string {
+ k := sec.Key(key)
+ if k != nil && k.String() != "" {
+ return k.String()
+ }
+ if len(def) > 0 {
+ return def[0]
+ }
+ return ""
+}
+
+func (s *iniConfigSection) Name() string {
+ return s.sec.Name()
+}
+
+func (s *iniConfigSection) MapTo(v any) error {
+ return s.sec.MapTo(v)
+}
+
+func (s *iniConfigSection) HasKey(key string) bool {
+ return s.sec.HasKey(key)
+}
+
+func (s *iniConfigSection) NewKey(name, value string) (ConfigKey, error) {
+ return s.sec.NewKey(name, value)
+}
+
+func (s *iniConfigSection) Key(key string) ConfigKey {
+ return s.sec.Key(key)
+}
+
+func (s *iniConfigSection) Keys() (keys []ConfigKey) {
+ for _, k := range s.sec.Keys() {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func (s *iniConfigSection) ChildSections() (sections []ConfigSection) {
+ for _, s := range s.sec.ChildSections() {
+ sections = append(sections, &iniConfigSection{s})
+ }
+ return sections
+}
+
+func configProviderLoadOptions() ini.LoadOptions {
+ return ini.LoadOptions{
+ KeyValueDelimiterOnWrite: " = ",
+ IgnoreContinuation: true,
+ }
+}
+
+// NewConfigProviderFromData this function is mainly for testing purpose
+func NewConfigProviderFromData(configContent string) (ConfigProvider, error) {
+ cfg, err := ini.LoadSources(configProviderLoadOptions(), strings.NewReader(configContent))
+ if err != nil {
+ return nil, err
+ }
+ cfg.NameMapper = ini.SnackCase
+ return &iniConfigProvider{
+ ini: cfg,
+ loadedFromEmpty: true,
+ }, nil
+}
+
+// NewConfigProviderFromFile load configuration from file.
+// NOTE: do not print any log except error.
+func NewConfigProviderFromFile(file string) (ConfigProvider, error) {
+ cfg := ini.Empty(configProviderLoadOptions())
+ loadedFromEmpty := true
+
+ if file != "" {
+ isFile, err := util.IsFile(file)
+ if err != nil {
+ return nil, fmt.Errorf("unable to check if %q is a file. Error: %v", file, err)
+ }
+ if isFile {
+ if err = cfg.Append(file); err != nil {
+ return nil, fmt.Errorf("failed to load config file %q: %v", file, err)
+ }
+ loadedFromEmpty = false
+ }
+ }
+
+ cfg.NameMapper = ini.SnackCase
+ return &iniConfigProvider{
+ file: file,
+ ini: cfg,
+ loadedFromEmpty: loadedFromEmpty,
+ }, nil
+}
+
+func (p *iniConfigProvider) Section(section string) ConfigSection {
+ return &iniConfigSection{sec: p.ini.Section(section)}
+}
+
+func (p *iniConfigProvider) Sections() (sections []ConfigSection) {
+ for _, s := range p.ini.Sections() {
+ sections = append(sections, &iniConfigSection{s})
+ }
+ return sections
+}
+
+func (p *iniConfigProvider) NewSection(name string) (ConfigSection, error) {
+ sec, err := p.ini.NewSection(name)
+ if err != nil {
+ return nil, err
+ }
+ return &iniConfigSection{sec: sec}, nil
+}
+
+func (p *iniConfigProvider) GetSection(name string) (ConfigSection, error) {
+ sec, err := p.ini.GetSection(name)
+ if err != nil {
+ return nil, err
+ }
+ return &iniConfigSection{sec: sec}, nil
+}
+
+var errDisableSaving = errors.New("this config can't be saved, developers should prepare a new config to save")
+
+func (p *iniConfigProvider) GetFile() string {
+ return p.file
+}
+
+// Save saves the content into file
+func (p *iniConfigProvider) Save() error {
+ if p.disableSaving {
+ return errDisableSaving
+ }
+ filename := p.file
+ if filename == "" {
+ return fmt.Errorf("config file path must not be empty")
+ }
+ if p.loadedFromEmpty {
+ if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil {
+ return fmt.Errorf("failed to create %q: %v", filename, err)
+ }
+ }
+ if err := p.ini.SaveTo(filename); err != nil {
+ return fmt.Errorf("failed to save %q: %v", filename, err)
+ }
+
+ // Change permissions to be more restrictive
+ fi, err := os.Stat(filename)
+ if err != nil {
+ return fmt.Errorf("failed to determine current conf file permissions: %v", err)
+ }
+
+ if fi.Mode().Perm() > 0o600 {
+ if err = os.Chmod(filename, 0o600); err != nil {
+ log.Warn("Failed changing conf file permissions to -rw-------. Consider changing them manually.")
+ }
+ }
+ return nil
+}
+
+func (p *iniConfigProvider) SaveTo(filename string) error {
+ if p.disableSaving {
+ return errDisableSaving
+ }
+ return p.ini.SaveTo(filename)
+}
+
+// DisableSaving disables the saving function, use PrepareSaving to get clear config options.
+func (p *iniConfigProvider) DisableSaving() {
+ p.disableSaving = true
+}
+
+// PrepareSaving loads the ini from file again to get clear config options.
+// Otherwise, the "MustXxx" calls would have polluted the current config provider,
+// it makes the "Save" outputs a lot of garbage options
+// After the INI package gets refactored, no "MustXxx" pollution, this workaround can be dropped.
+func (p *iniConfigProvider) PrepareSaving() (ConfigProvider, error) {
+ if p.file == "" {
+ return nil, errors.New("no config file to save")
+ }
+ return NewConfigProviderFromFile(p.file)
+}
+
+func (p *iniConfigProvider) IsLoadedFromEmpty() bool {
+ return p.loadedFromEmpty
+}
+
+func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting any) {
+ if err := rootCfg.Section(sectionName).MapTo(setting); err != nil {
+ log.Fatal("Failed to map %s settings: %v", sectionName, err)
+ }
+}
+
+// DeprecatedWarnings contains the warning message for various deprecations, including: setting option, file/folder, etc
+var DeprecatedWarnings []string
+
+func deprecatedSetting(rootCfg ConfigProvider, oldSection, oldKey, newSection, newKey, version string) {
+ if rootCfg.Section(oldSection).HasKey(oldKey) {
+ msg := fmt.Sprintf("Deprecated config option `[%s]` `%s` present. Use `[%s]` `%s` instead. This fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version)
+ log.Error("%v", msg)
+ DeprecatedWarnings = append(DeprecatedWarnings, msg)
+ }
+}
+
+// deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini
+func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) {
+ if rootCfg.Section(oldSection).HasKey(oldKey) {
+ log.Error("Deprecated `[%s]` `%s` present which has been copied to database table sys_setting", oldSection, oldKey)
+ }
+}
+
+// NewConfigProviderForLocale loads locale configuration from source and others. "string" if for a local file path, "[]byte" is for INI content
+func NewConfigProviderForLocale(source any, others ...any) (ConfigProvider, error) {
+ iniFile, err := ini.LoadSources(ini.LoadOptions{
+ IgnoreInlineComment: true,
+ UnescapeValueCommentSymbols: true,
+ IgnoreContinuation: true,
+ }, source, others...)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load locale ini: %w", err)
+ }
+ iniFile.BlockMode = false
+ return &iniConfigProvider{
+ ini: iniFile,
+ loadedFromEmpty: true,
+ }, nil
+}
+
+func init() {
+ ini.PrettyFormat = false
+}
diff --git a/modules/setting/config_provider_test.go b/modules/setting/config_provider_test.go
new file mode 100644
index 0000000..702be80
--- /dev/null
+++ b/modules/setting/config_provider_test.go
@@ -0,0 +1,157 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigProviderBehaviors(t *testing.T) {
+ t.Run("BuggyKeyOverwritten", func(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData(`
+[foo]
+key =
+`)
+ sec := cfg.Section("foo")
+ secSub := cfg.Section("foo.bar")
+ secSub.Key("key").MustString("1") // try to read a key from subsection
+ assert.Equal(t, "1", sec.Key("key").String()) // TODO: BUGGY! the key in [foo] is overwritten
+ })
+
+ t.Run("SubsectionSeeParentKeys", func(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData(`
+[foo]
+key = 123
+`)
+ secSub := cfg.Section("foo.bar.xxx")
+ assert.Equal(t, "123", secSub.Key("key").String())
+ })
+ t.Run("TrailingSlash", func(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData(`
+[foo]
+key = E:\
+xxx = yyy
+`)
+ sec := cfg.Section("foo")
+ assert.Equal(t, "E:\\", sec.Key("key").String())
+ assert.Equal(t, "yyy", sec.Key("xxx").String())
+ })
+}
+
+func TestConfigProviderHelper(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData(`
+[foo]
+empty =
+key = 123
+`)
+
+ sec := cfg.Section("foo")
+ secSub := cfg.Section("foo.bar")
+
+ // test empty key
+ assert.Equal(t, "def", ConfigSectionKeyString(sec, "empty", "def"))
+ assert.Equal(t, "xyz", ConfigSectionKeyString(secSub, "empty", "xyz"))
+
+ // test non-inherited key, only see the keys in current section
+ assert.NotNil(t, ConfigSectionKey(sec, "key"))
+ assert.Nil(t, ConfigSectionKey(secSub, "key"))
+
+ // test default behavior
+ assert.Equal(t, "123", ConfigSectionKeyString(sec, "key"))
+ assert.Equal(t, "", ConfigSectionKeyString(secSub, "key"))
+ assert.Equal(t, "def", ConfigSectionKeyString(secSub, "key", "def"))
+
+ assert.Equal(t, "123", ConfigInheritedKeyString(secSub, "key"))
+
+ // Workaround for ini package's BuggyKeyOverwritten behavior
+ assert.Equal(t, "", ConfigSectionKeyString(sec, "empty"))
+ assert.Equal(t, "", ConfigSectionKeyString(secSub, "empty"))
+ assert.Equal(t, "def", ConfigInheritedKey(secSub, "empty").MustString("def"))
+ assert.Equal(t, "def", ConfigInheritedKey(secSub, "empty").MustString("xyz"))
+ assert.Equal(t, "", ConfigSectionKeyString(sec, "empty"))
+ assert.Equal(t, "def", ConfigSectionKeyString(secSub, "empty"))
+}
+
+func TestNewConfigProviderFromFile(t *testing.T) {
+ cfg, err := NewConfigProviderFromFile("no-such.ini")
+ require.NoError(t, err)
+ assert.True(t, cfg.IsLoadedFromEmpty())
+
+ // load non-existing file and save
+ testFile := t.TempDir() + "/test.ini"
+ testFile1 := t.TempDir() + "/test1.ini"
+ cfg, err = NewConfigProviderFromFile(testFile)
+ require.NoError(t, err)
+
+ sec, _ := cfg.NewSection("foo")
+ _, _ = sec.NewKey("k1", "a")
+ require.NoError(t, cfg.Save())
+ _, _ = sec.NewKey("k2", "b")
+ require.NoError(t, cfg.SaveTo(testFile1))
+
+ bs, err := os.ReadFile(testFile)
+ require.NoError(t, err)
+ assert.Equal(t, "[foo]\nk1 = a\n", string(bs))
+
+ bs, err = os.ReadFile(testFile1)
+ require.NoError(t, err)
+ assert.Equal(t, "[foo]\nk1 = a\nk2 = b\n", string(bs))
+
+ // load existing file and save
+ cfg, err = NewConfigProviderFromFile(testFile)
+ require.NoError(t, err)
+ assert.Equal(t, "a", cfg.Section("foo").Key("k1").String())
+ sec, _ = cfg.NewSection("bar")
+ _, _ = sec.NewKey("k1", "b")
+ require.NoError(t, cfg.Save())
+ bs, err = os.ReadFile(testFile)
+ require.NoError(t, err)
+ assert.Equal(t, "[foo]\nk1 = a\n\n[bar]\nk1 = b\n", string(bs))
+}
+
+func TestNewConfigProviderForLocale(t *testing.T) {
+ // load locale from file
+ localeFile := t.TempDir() + "/locale.ini"
+ _ = os.WriteFile(localeFile, []byte(`k1=a`), 0o644)
+ cfg, err := NewConfigProviderForLocale(localeFile)
+ require.NoError(t, err)
+ assert.Equal(t, "a", cfg.Section("").Key("k1").String())
+
+ // load locale from bytes
+ cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar"))
+ require.NoError(t, err)
+ assert.Equal(t, "foo", cfg.Section("").Key("k1").String())
+ cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar"), []byte("k2=xxx"))
+ require.NoError(t, err)
+ assert.Equal(t, "foo", cfg.Section("").Key("k1").String())
+ assert.Equal(t, "xxx", cfg.Section("").Key("k2").String())
+}
+
+func TestDisableSaving(t *testing.T) {
+ testFile := t.TempDir() + "/test.ini"
+ _ = os.WriteFile(testFile, []byte("k1=a\nk2=b"), 0o644)
+ cfg, err := NewConfigProviderFromFile(testFile)
+ require.NoError(t, err)
+
+ cfg.DisableSaving()
+ err = cfg.Save()
+ require.ErrorIs(t, err, errDisableSaving)
+
+ saveCfg, err := cfg.PrepareSaving()
+ require.NoError(t, err)
+
+ saveCfg.Section("").Key("k1").MustString("x")
+ saveCfg.Section("").Key("k2").SetValue("y")
+ saveCfg.Section("").Key("k3").SetValue("z")
+ err = saveCfg.Save()
+ require.NoError(t, err)
+
+ bs, err := os.ReadFile(testFile)
+ require.NoError(t, err)
+ assert.Equal(t, "k1 = a\nk2 = y\nk3 = z\n", string(bs))
+}
diff --git a/modules/setting/cors.go b/modules/setting/cors.go
new file mode 100644
index 0000000..63daaad
--- /dev/null
+++ b/modules/setting/cors.go
@@ -0,0 +1,34 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// CORSConfig defines CORS settings
+var CORSConfig = struct {
+ Enabled bool
+ AllowDomain []string // FIXME: this option is from legacy code, it actually works as "AllowedOrigins". When refactoring in the future, the config option should also be renamed together.
+ Methods []string
+ MaxAge time.Duration
+ AllowCredentials bool
+ Headers []string
+ XFrameOptions string
+}{
+ AllowDomain: []string{"*"},
+ Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
+ Headers: []string{"Content-Type", "User-Agent"},
+ MaxAge: 10 * time.Minute,
+ XFrameOptions: "SAMEORIGIN",
+}
+
+func loadCorsFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "cors", &CORSConfig)
+ if CORSConfig.Enabled {
+ log.Info("CORS Service Enabled")
+ }
+}
diff --git a/modules/setting/cron.go b/modules/setting/cron.go
new file mode 100644
index 0000000..7c4cc44
--- /dev/null
+++ b/modules/setting/cron.go
@@ -0,0 +1,32 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import "reflect"
+
+// GetCronSettings maps the cron subsection to the provided config
+func GetCronSettings(name string, config any) (any, error) {
+ return getCronSettings(CfgProvider, name, config)
+}
+
+func getCronSettings(rootCfg ConfigProvider, name string, config any) (any, error) {
+ if err := rootCfg.Section("cron." + name).MapTo(config); err != nil {
+ return config, err
+ }
+
+ typ := reflect.TypeOf(config).Elem()
+ val := reflect.ValueOf(config).Elem()
+
+ for i := 0; i < typ.NumField(); i++ {
+ field := val.Field(i)
+ tpField := typ.Field(i)
+ if tpField.Type.Kind() == reflect.Struct && tpField.Anonymous {
+ if err := rootCfg.Section("cron." + name).MapTo(field.Addr().Interface()); err != nil {
+ return config, err
+ }
+ }
+ }
+
+ return config, nil
+}
diff --git a/modules/setting/cron_test.go b/modules/setting/cron_test.go
new file mode 100644
index 0000000..32f8ecf
--- /dev/null
+++ b/modules/setting/cron_test.go
@@ -0,0 +1,44 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getCronSettings(t *testing.T) {
+ type BaseStruct struct {
+ Base bool
+ Second string
+ }
+
+ type Extended struct {
+ BaseStruct
+ Extend bool
+ }
+
+ iniStr := `
+[cron.test]
+BASE = true
+SECOND = white rabbit
+EXTEND = true
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ extended := &Extended{
+ BaseStruct: BaseStruct{
+ Second: "queen of hearts",
+ },
+ }
+
+ _, err = getCronSettings(cfg, "test", extended)
+ require.NoError(t, err)
+ assert.True(t, extended.Base)
+ assert.EqualValues(t, "white rabbit", extended.Second)
+ assert.True(t, extended.Extend)
+}
diff --git a/modules/setting/database.go b/modules/setting/database.go
new file mode 100644
index 0000000..76fae27
--- /dev/null
+++ b/modules/setting/database.go
@@ -0,0 +1,204 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var (
+ // SupportedDatabaseTypes includes all XORM supported databases type, sqlite3 maybe added by `database_sqlite3.go`
+ SupportedDatabaseTypes = []string{"mysql", "postgres"}
+ // DatabaseTypeNames contains the friendly names for all database types
+ DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "sqlite3": "SQLite3"}
+
+ // EnableSQLite3 use SQLite3, set by build flag
+ EnableSQLite3 bool
+
+ // Database holds the database settings
+ Database = struct {
+ Type DatabaseType
+ Host string
+ Name string
+ User string
+ Passwd string
+ Schema string
+ SSLMode string
+ Path string
+ LogSQL bool
+ MysqlCharset string
+ CharsetCollation string
+ Timeout int // seconds
+ SQLiteJournalMode string
+ DBConnectRetries int
+ DBConnectBackoff time.Duration
+ MaxIdleConns int
+ MaxOpenConns int
+ ConnMaxIdleTime time.Duration
+ ConnMaxLifetime time.Duration
+ IterateBufferSize int
+ AutoMigration bool
+ SlowQueryThreshold time.Duration
+ }{
+ Timeout: 500,
+ IterateBufferSize: 50,
+ }
+)
+
+// LoadDBSetting loads the database settings
+func LoadDBSetting() {
+ loadDBSetting(CfgProvider)
+}
+
+func loadDBSetting(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("database")
+ Database.Type = DatabaseType(sec.Key("DB_TYPE").String())
+
+ Database.Host = sec.Key("HOST").String()
+ Database.Name = sec.Key("NAME").String()
+ Database.User = sec.Key("USER").String()
+ if len(Database.Passwd) == 0 {
+ Database.Passwd = sec.Key("PASSWD").String()
+ }
+ Database.Schema = sec.Key("SCHEMA").String()
+ Database.SSLMode = sec.Key("SSL_MODE").MustString("disable")
+ Database.CharsetCollation = sec.Key("CHARSET_COLLATION").String()
+
+ Database.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "forgejo.db"))
+ Database.Timeout = sec.Key("SQLITE_TIMEOUT").MustInt(500)
+ Database.SQLiteJournalMode = sec.Key("SQLITE_JOURNAL_MODE").MustString("")
+
+ Database.MaxIdleConns = sec.Key("MAX_IDLE_CONNS").MustInt(2)
+ if Database.Type.IsMySQL() {
+ Database.ConnMaxLifetime = sec.Key("CONN_MAX_LIFETIME").MustDuration(3 * time.Second)
+ } else {
+ Database.ConnMaxLifetime = sec.Key("CONN_MAX_LIFETIME").MustDuration(0)
+ }
+ Database.ConnMaxIdleTime = sec.Key("CONN_MAX_IDLETIME").MustDuration(0)
+ Database.MaxOpenConns = sec.Key("MAX_OPEN_CONNS").MustInt(100)
+
+ Database.IterateBufferSize = sec.Key("ITERATE_BUFFER_SIZE").MustInt(50)
+ Database.LogSQL = sec.Key("LOG_SQL").MustBool(false)
+ Database.DBConnectRetries = sec.Key("DB_RETRIES").MustInt(10)
+ Database.DBConnectBackoff = sec.Key("DB_RETRY_BACKOFF").MustDuration(3 * time.Second)
+ Database.AutoMigration = sec.Key("AUTO_MIGRATION").MustBool(true)
+
+ deprecatedSetting(rootCfg, "database", "SLOW_QUERY_TRESHOLD", "database", "SLOW_QUERY_THRESHOLD", "1.23")
+ if sec.HasKey("SLOW_QUERY_TRESHOLD") && !sec.HasKey("SLOW_QUERY_THRESHOLD") {
+ Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_TRESHOLD").MustDuration(5 * time.Second)
+ } else {
+ Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_THRESHOLD").MustDuration(5 * time.Second)
+ }
+}
+
+// DBConnStr returns database connection string
+func DBConnStr() (string, error) {
+ var connStr string
+ paramSep := "?"
+ if strings.Contains(Database.Name, paramSep) {
+ paramSep = "&"
+ }
+ switch Database.Type {
+ case "mysql":
+ connType := "tcp"
+ if len(Database.Host) > 0 && Database.Host[0] == '/' { // looks like a unix socket
+ connType = "unix"
+ }
+ tls := Database.SSLMode
+ if tls == "disable" { // allow (Postgres-inspired) default value to work in MySQL
+ tls = "false"
+ }
+ connStr = fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s",
+ Database.User, Database.Passwd, connType, Database.Host, Database.Name, paramSep, tls)
+ case "postgres":
+ connStr = getPostgreSQLConnectionString(Database.Host, Database.User, Database.Passwd, Database.Name, Database.SSLMode)
+ case "sqlite3":
+ if !EnableSQLite3 {
+ return "", errors.New("this Gitea binary was not built with SQLite3 support")
+ }
+ if err := os.MkdirAll(filepath.Dir(Database.Path), os.ModePerm); err != nil {
+ return "", fmt.Errorf("Failed to create directories: %w", err)
+ }
+ journalMode := ""
+ if Database.SQLiteJournalMode != "" {
+ journalMode = "&_journal_mode=" + Database.SQLiteJournalMode
+ }
+ connStr = fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate%s",
+ Database.Path, Database.Timeout, journalMode)
+ default:
+ return "", fmt.Errorf("unknown database type: %s", Database.Type)
+ }
+
+ return connStr, nil
+}
+
+// parsePostgreSQLHostPort parses given input in various forms defined in
+// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING
+// and returns proper host and port number.
+func parsePostgreSQLHostPort(info string) (host, port string) {
+ if h, p, err := net.SplitHostPort(info); err == nil {
+ host, port = h, p
+ } else {
+ // treat the "info" as "host", if it's an IPv6 address, remove the wrapper
+ host = info
+ if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
+ host = host[1 : len(host)-1]
+ }
+ }
+
+ // set fallback values
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ if port == "" {
+ port = "5432"
+ }
+ return host, port
+}
+
+func getPostgreSQLConnectionString(dbHost, dbUser, dbPasswd, dbName, dbsslMode string) (connStr string) {
+ dbName, dbParam, _ := strings.Cut(dbName, "?")
+ host, port := parsePostgreSQLHostPort(dbHost)
+ connURL := url.URL{
+ Scheme: "postgres",
+ User: url.UserPassword(dbUser, dbPasswd),
+ Host: net.JoinHostPort(host, port),
+ Path: dbName,
+ OmitHost: false,
+ RawQuery: dbParam,
+ }
+ query := connURL.Query()
+ if strings.HasPrefix(host, "/") { // looks like a unix socket
+ query.Add("host", host)
+ connURL.Host = ":" + port
+ }
+ query.Set("sslmode", dbsslMode)
+ connURL.RawQuery = query.Encode()
+ return connURL.String()
+}
+
+type DatabaseType string
+
+func (t DatabaseType) String() string {
+ return string(t)
+}
+
+func (t DatabaseType) IsSQLite3() bool {
+ return t == "sqlite3"
+}
+
+func (t DatabaseType) IsMySQL() bool {
+ return t == "mysql"
+}
+
+func (t DatabaseType) IsPostgreSQL() bool {
+ return t == "postgres"
+}
diff --git a/modules/setting/database_sqlite.go b/modules/setting/database_sqlite.go
new file mode 100644
index 0000000..c1037cf
--- /dev/null
+++ b/modules/setting/database_sqlite.go
@@ -0,0 +1,15 @@
+//go:build sqlite
+
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func init() {
+ EnableSQLite3 = true
+ SupportedDatabaseTypes = append(SupportedDatabaseTypes, "sqlite3")
+}
diff --git a/modules/setting/database_test.go b/modules/setting/database_test.go
new file mode 100644
index 0000000..a742d54
--- /dev/null
+++ b/modules/setting/database_test.go
@@ -0,0 +1,109 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_parsePostgreSQLHostPort(t *testing.T) {
+ tests := map[string]struct {
+ HostPort string
+ Host string
+ Port string
+ }{
+ "host-port": {
+ HostPort: "127.0.0.1:1234",
+ Host: "127.0.0.1",
+ Port: "1234",
+ },
+ "no-port": {
+ HostPort: "127.0.0.1",
+ Host: "127.0.0.1",
+ Port: "5432",
+ },
+ "ipv6-port": {
+ HostPort: "[::1]:1234",
+ Host: "::1",
+ Port: "1234",
+ },
+ "ipv6-no-port": {
+ HostPort: "[::1]",
+ Host: "::1",
+ Port: "5432",
+ },
+ "unix-socket": {
+ HostPort: "/tmp/pg.sock:1234",
+ Host: "/tmp/pg.sock",
+ Port: "1234",
+ },
+ "unix-socket-no-port": {
+ HostPort: "/tmp/pg.sock",
+ Host: "/tmp/pg.sock",
+ Port: "5432",
+ },
+ }
+ for k, test := range tests {
+ t.Run(k, func(t *testing.T) {
+ t.Log(test.HostPort)
+ host, port := parsePostgreSQLHostPort(test.HostPort)
+ assert.Equal(t, test.Host, host)
+ assert.Equal(t, test.Port, port)
+ })
+ }
+}
+
+func Test_getPostgreSQLConnectionString(t *testing.T) {
+ tests := []struct {
+ Host string
+ User string
+ Passwd string
+ Name string
+ SSLMode string
+ Output string
+ }{
+ {
+ Host: "", // empty means default
+ Output: "postgres://:@127.0.0.1:5432?sslmode=",
+ },
+ {
+ Host: "/tmp/pg.sock",
+ User: "testuser",
+ Passwd: "space space !#$%^^%^```-=?=",
+ Name: "gitea",
+ SSLMode: "false",
+ Output: "postgres://testuser:space%20space%20%21%23$%25%5E%5E%25%5E%60%60%60-=%3F=@:5432/gitea?host=%2Ftmp%2Fpg.sock&sslmode=false",
+ },
+ {
+ Host: "/tmp/pg.sock:6432",
+ User: "testuser",
+ Passwd: "pass",
+ Name: "gitea",
+ SSLMode: "false",
+ Output: "postgres://testuser:pass@:6432/gitea?host=%2Ftmp%2Fpg.sock&sslmode=false",
+ },
+ {
+ Host: "localhost",
+ User: "pgsqlusername",
+ Passwd: "I love Gitea!",
+ Name: "gitea",
+ SSLMode: "true",
+ Output: "postgres://pgsqlusername:I%20love%20Gitea%21@localhost:5432/gitea?sslmode=true",
+ },
+ {
+ Host: "localhost:1234",
+ User: "user",
+ Passwd: "pass",
+ Name: "gitea?param=1",
+ Output: "postgres://user:pass@localhost:1234/gitea?param=1&sslmode=",
+ },
+ }
+
+ for _, test := range tests {
+ connStr := getPostgreSQLConnectionString(test.Host, test.User, test.Passwd, test.Name, test.SSLMode)
+ assert.Equal(t, test.Output, connStr)
+ }
+}
diff --git a/modules/setting/f3.go b/modules/setting/f3.go
new file mode 100644
index 0000000..8669b70
--- /dev/null
+++ b/modules/setting/f3.go
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Friendly Forge Format (F3) settings
+var (
+ F3 = struct {
+ Enabled bool
+ }{
+ Enabled: false,
+ }
+)
+
+func LoadF3Setting() {
+ loadF3From(CfgProvider)
+}
+
+func loadF3From(rootCfg ConfigProvider) {
+ if err := rootCfg.Section("F3").MapTo(&F3); err != nil {
+ log.Fatal("Failed to map F3 settings: %v", err)
+ }
+}
diff --git a/modules/setting/federation.go b/modules/setting/federation.go
new file mode 100644
index 0000000..aeb3068
--- /dev/null
+++ b/modules/setting/federation.go
@@ -0,0 +1,51 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/go-fed/httpsig"
+)
+
+// Federation settings
+var (
+ Federation = struct {
+ Enabled bool
+ ShareUserStatistics bool
+ MaxSize int64
+ Algorithms []string
+ DigestAlgorithm string
+ GetHeaders []string
+ PostHeaders []string
+ }{
+ Enabled: false,
+ ShareUserStatistics: true,
+ MaxSize: 4,
+ Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
+ DigestAlgorithm: "SHA-256",
+ GetHeaders: []string{"(request-target)", "Date", "Host"},
+ PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
+ }
+)
+
+// HttpsigAlgs is a constant slice of httpsig algorithm objects
+var HttpsigAlgs []httpsig.Algorithm
+
+func loadFederationFrom(rootCfg ConfigProvider) {
+ if err := rootCfg.Section("federation").MapTo(&Federation); err != nil {
+ log.Fatal("Failed to map Federation settings: %v", err)
+ } else if !httpsig.IsSupportedDigestAlgorithm(Federation.DigestAlgorithm) {
+ log.Fatal("unsupported digest algorithm: %s", Federation.DigestAlgorithm)
+ return
+ }
+
+ // Get MaxSize in bytes instead of MiB
+ Federation.MaxSize = 1 << 20 * Federation.MaxSize
+
+ HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms))
+ for i, alg := range Federation.Algorithms {
+ HttpsigAlgs[i] = httpsig.Algorithm(alg)
+ }
+}
diff --git a/modules/setting/forgejo_storage_test.go b/modules/setting/forgejo_storage_test.go
new file mode 100644
index 0000000..d91bff5
--- /dev/null
+++ b/modules/setting/forgejo_storage_test.go
@@ -0,0 +1,264 @@
+// SPDX-License-Identifier: MIT
+
+//
+// Tests verifying the Forgejo documentation on storage settings is correct
+//
+// https://forgejo.org/docs/v1.20/admin/storage/
+//
+
+package setting
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestForgejoDocs_StorageTypes(t *testing.T) {
+ iniStr := `
+[server]
+APP_DATA_PATH = /
+`
+ testStorageTypesDefaultAndSpecificStorage(t, iniStr)
+}
+
+func testStorageGetPath(storage *Storage) string {
+ if storage.Type == MinioStorageType {
+ return storage.MinioConfig.BasePath
+ }
+ return storage.Path
+}
+
+var testSectionToBasePath = map[string]string{
+ "attachment": "attachments",
+ "lfs": "lfs",
+ "avatar": "avatars",
+ "repo-avatar": "repo-avatars",
+ "repo-archive": "repo-archive",
+ "packages": "packages",
+ "storage.actions_log": "actions_log",
+ "actions.artifacts": "actions_artifacts",
+}
+
+type testSectionToPathFun func(StorageType, string) string
+
+func testBuildPath(t StorageType, path string) string {
+ if t == LocalStorageType {
+ return "/" + path
+ }
+ return path + "/"
+}
+
+func testSectionToPath(t StorageType, section string) string {
+ return testBuildPath(t, testSectionToBasePath[section])
+}
+
+func testSpecificPath(t StorageType, section string) string {
+ if t == LocalStorageType {
+ return "/specific_local_path"
+ }
+ return "specific_s3_base_path/"
+}
+
+func testDefaultDir(t StorageType) string {
+ if t == LocalStorageType {
+ return "default_local_path"
+ }
+ return "default_s3_base_path"
+}
+
+func testDefaultPath(t StorageType) string {
+ return testBuildPath(t, testDefaultDir(t))
+}
+
+func testSectionToDefaultPath(t StorageType, section string) string {
+ return testBuildPath(t, filepath.Join(testDefaultDir(t), testSectionToPath(t, section)))
+}
+
+func testLegacyPath(t StorageType, section string) string {
+ return testBuildPath(t, fmt.Sprintf("legacy_%s_path", section))
+}
+
+func testStorageTypeToSetting(t StorageType) string {
+ if t == LocalStorageType {
+ return "PATH"
+ }
+ return "MINIO_BASE_PATH"
+}
+
+var testSectionToLegacy = map[string]string{
+ "lfs": fmt.Sprintf(`
+[server]
+APP_DATA_PATH = /
+LFS_CONTENT_PATH = %s
+`, testLegacyPath(LocalStorageType, "lfs")),
+ "avatar": fmt.Sprintf(`
+[picture]
+AVATAR_UPLOAD_PATH = %s
+`, testLegacyPath(LocalStorageType, "avatar")),
+ "repo-avatar": fmt.Sprintf(`
+[picture]
+REPOSITORY_AVATAR_UPLOAD_PATH = %s
+`, testLegacyPath(LocalStorageType, "repo-avatar")),
+}
+
+func testStorageTypesDefaultAndSpecificStorage(t *testing.T, iniStr string) {
+ storageType := MinioStorageType
+ t.Run(string(storageType), func(t *testing.T) {
+ t.Run("override type minio", func(t *testing.T) {
+ storageSection := `
+[storage]
+STORAGE_TYPE = minio
+`
+ testStorageTypesSpecificStorages(t, iniStr+storageSection, storageType, testSectionToPath, testSectionToPath)
+ })
+ })
+
+ storageType = LocalStorageType
+
+ t.Run(string(storageType), func(t *testing.T) {
+ storageSection := ""
+ testStorageTypesSpecificStorages(t, iniStr+storageSection, storageType, testSectionToPath, testSectionToPath)
+
+ t.Run("override type local", func(t *testing.T) {
+ storageSection := `
+[storage]
+STORAGE_TYPE = local
+`
+ testStorageTypesSpecificStorages(t, iniStr+storageSection, storageType, testSectionToPath, testSectionToPath)
+
+ storageSection = fmt.Sprintf(`
+[storage]
+STORAGE_TYPE = local
+PATH = %s
+`, testDefaultPath(LocalStorageType))
+ testStorageTypesSpecificStorageSections(t, iniStr+storageSection, storageType, testSectionToDefaultPath, testSectionToPath)
+ })
+ })
+}
+
+func testStorageTypesSpecificStorageSections(t *testing.T, iniStr string, defaultStorageType StorageType, defaultStorageTypePath, testSectionToPath testSectionToPathFun) {
+ testSectionsMap := map[string]**Storage{
+ "attachment": &Attachment.Storage,
+ "lfs": &LFS.Storage,
+ "avatar": &Avatar.Storage,
+ "repo-avatar": &RepoAvatar.Storage,
+ "repo-archive": &RepoArchive.Storage,
+ "packages": &Packages.Storage,
+ // there are inconsistencies in how actions storage is determined in v1.20
+ // it is still alpha and undocumented and is ignored for now
+ //"storage.actions_log": &Actions.LogStorage,
+ //"actions.artifacts": &Actions.ArtifactStorage,
+ }
+
+ for sectionName, storage := range testSectionsMap {
+ t.Run(sectionName, func(t *testing.T) {
+ testStorageTypesSpecificStorage(t, iniStr, defaultStorageType, defaultStorageTypePath, testSectionToPath, sectionName, storage)
+ })
+ }
+}
+
+func testStorageTypesSpecificStorages(t *testing.T, iniStr string, defaultStorageType StorageType, defaultStorageTypePath, testSectionToPath testSectionToPathFun) {
+ testSectionsMap := map[string]**Storage{
+ "attachment": &Attachment.Storage,
+ "lfs": &LFS.Storage,
+ "avatar": &Avatar.Storage,
+ "repo-avatar": &RepoAvatar.Storage,
+ "repo-archive": &RepoArchive.Storage,
+ "packages": &Packages.Storage,
+ "storage.actions_log": &Actions.LogStorage,
+ "actions.artifacts": &Actions.ArtifactStorage,
+ }
+
+ for sectionName, storage := range testSectionsMap {
+ t.Run(sectionName, func(t *testing.T) {
+ if legacy, ok := testSectionToLegacy[sectionName]; ok {
+ if defaultStorageType == LocalStorageType {
+ t.Run("legacy local", func(t *testing.T) {
+ testStorageTypesSpecificStorage(t, iniStr+legacy, LocalStorageType, testLegacyPath, testSectionToPath, sectionName, storage)
+ testStorageTypesSpecificStorageTypeOverride(t, iniStr+legacy, LocalStorageType, testLegacyPath, testSectionToPath, sectionName, storage)
+ })
+ } else {
+ t.Run("legacy minio", func(t *testing.T) {
+ testStorageTypesSpecificStorage(t, iniStr+legacy, MinioStorageType, defaultStorageTypePath, testSectionToPath, sectionName, storage)
+ testStorageTypesSpecificStorageTypeOverride(t, iniStr+legacy, LocalStorageType, testLegacyPath, testSectionToPath, sectionName, storage)
+ })
+ }
+ }
+ for _, specificStorageType := range storageTypes {
+ testStorageTypesSpecificStorageTypeOverride(t, iniStr, specificStorageType, defaultStorageTypePath, testSectionToPath, sectionName, storage)
+ }
+ })
+ }
+}
+
+func testStorageTypesSpecificStorage(t *testing.T, iniStr string, defaultStorageType StorageType, defaultStorageTypePath, testSectionToPath testSectionToPathFun, sectionName string, storage **Storage) {
+ var section string
+
+ //
+ // Specific section is absent
+ //
+ testStoragePathMatch(t, iniStr, defaultStorageType, defaultStorageTypePath, sectionName, storage)
+
+ //
+ // Specific section is empty
+ //
+ section = fmt.Sprintf(`
+[%s]
+`,
+ sectionName)
+ testStoragePathMatch(t, iniStr+section, defaultStorageType, defaultStorageTypePath, sectionName, storage)
+
+ //
+ // Specific section with a path override
+ //
+ section = fmt.Sprintf(`
+[%s]
+%s = %s
+`,
+ sectionName,
+ testStorageTypeToSetting(defaultStorageType),
+ testSpecificPath(defaultStorageType, ""))
+ testStoragePathMatch(t, iniStr+section, defaultStorageType, testSpecificPath, sectionName, storage)
+}
+
+func testStorageTypesSpecificStorageTypeOverride(t *testing.T, iniStr string, overrideStorageType StorageType, defaultStorageTypePath, testSectionToPath testSectionToPathFun, sectionName string, storage **Storage) {
+ var section string
+ t.Run("specific-"+string(overrideStorageType), func(t *testing.T) {
+ //
+ // Specific section with a path and storage type override
+ //
+ section = fmt.Sprintf(`
+[%s]
+STORAGE_TYPE = %s
+%s = %s
+`,
+ sectionName,
+ overrideStorageType,
+ testStorageTypeToSetting(overrideStorageType),
+ testSpecificPath(overrideStorageType, ""))
+ testStoragePathMatch(t, iniStr+section, overrideStorageType, testSpecificPath, sectionName, storage)
+
+ //
+ // Specific section with type override
+ //
+ section = fmt.Sprintf(`
+[%s]
+STORAGE_TYPE = %s
+`,
+ sectionName,
+ overrideStorageType)
+ testStoragePathMatch(t, iniStr+section, overrideStorageType, defaultStorageTypePath, sectionName, storage)
+ })
+}
+
+func testStoragePathMatch(t *testing.T, iniStr string, storageType StorageType, testSectionToPath testSectionToPathFun, section string, storage **Storage) {
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err, iniStr)
+ require.NoError(t, loadCommonSettingsFrom(cfg), iniStr)
+ assert.EqualValues(t, testSectionToPath(storageType, section), testStorageGetPath(*storage), iniStr)
+ assert.EqualValues(t, storageType, (*storage).Type, iniStr)
+}
diff --git a/modules/setting/git.go b/modules/setting/git.go
new file mode 100644
index 0000000..812c4fe
--- /dev/null
+++ b/modules/setting/git.go
@@ -0,0 +1,123 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Git settings
+var Git = struct {
+ Path string
+ HomePath string
+ DisableDiffHighlight bool
+
+ MaxGitDiffLines int
+ MaxGitDiffLineCharacters int
+ MaxGitDiffFiles int
+ CommitsRangeSize int // CommitsRangeSize the default commits range size
+ BranchesRangeSize int // BranchesRangeSize the default branches range size
+ VerbosePush bool
+ VerbosePushDelay time.Duration
+ GCArgs []string `ini:"GC_ARGS" delim:" "`
+ EnableAutoGitWireProtocol bool
+ PullRequestPushMessage bool
+ LargeObjectThreshold int64
+ DisableCoreProtectNTFS bool
+ DisablePartialClone bool
+ Timeout struct {
+ Default int
+ Migrate int
+ Mirror int
+ Clone int
+ Pull int
+ GC int `ini:"GC"`
+ Grep int
+ } `ini:"git.timeout"`
+}{
+ DisableDiffHighlight: false,
+ MaxGitDiffLines: 1000,
+ MaxGitDiffLineCharacters: 5000,
+ MaxGitDiffFiles: 100,
+ CommitsRangeSize: 50,
+ BranchesRangeSize: 20,
+ VerbosePush: true,
+ VerbosePushDelay: 5 * time.Second,
+ GCArgs: []string{},
+ EnableAutoGitWireProtocol: true,
+ PullRequestPushMessage: true,
+ LargeObjectThreshold: 1024 * 1024,
+ DisablePartialClone: false,
+ Timeout: struct {
+ Default int
+ Migrate int
+ Mirror int
+ Clone int
+ Pull int
+ GC int `ini:"GC"`
+ Grep int
+ }{
+ Default: 360,
+ Migrate: 600,
+ Mirror: 300,
+ Clone: 300,
+ Pull: 300,
+ GC: 60,
+ Grep: 2,
+ },
+}
+
+type GitConfigType struct {
+ Options map[string]string // git config key is case-insensitive, always use lower-case
+}
+
+func (c *GitConfigType) SetOption(key, val string) {
+ c.Options[strings.ToLower(key)] = val
+}
+
+func (c *GitConfigType) GetOption(key string) string {
+ return c.Options[strings.ToLower(key)]
+}
+
+var GitConfig = GitConfigType{
+ Options: make(map[string]string),
+}
+
+func loadGitFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("git")
+ if err := sec.MapTo(&Git); err != nil {
+ log.Fatal("Failed to map Git settings: %v", err)
+ }
+
+ secGitConfig := rootCfg.Section("git.config")
+ GitConfig.Options = make(map[string]string)
+ GitConfig.SetOption("diff.algorithm", "histogram")
+ GitConfig.SetOption("core.logAllRefUpdates", "true")
+ GitConfig.SetOption("gc.reflogExpire", "90")
+
+ secGitReflog := rootCfg.Section("git.reflog")
+ if secGitReflog.HasKey("ENABLED") {
+ deprecatedSetting(rootCfg, "git.reflog", "ENABLED", "git.config", "core.logAllRefUpdates", "1.21")
+ GitConfig.SetOption("core.logAllRefUpdates", secGitReflog.Key("ENABLED").In("true", []string{"true", "false"}))
+ }
+ if secGitReflog.HasKey("EXPIRATION") {
+ deprecatedSetting(rootCfg, "git.reflog", "EXPIRATION", "git.config", "core.reflogExpire", "1.21")
+ GitConfig.SetOption("gc.reflogExpire", secGitReflog.Key("EXPIRATION").String())
+ }
+
+ for _, key := range secGitConfig.Keys() {
+ GitConfig.SetOption(key.Name(), key.String())
+ }
+
+ Git.HomePath = sec.Key("HOME_PATH").MustString("home")
+ if !filepath.IsAbs(Git.HomePath) {
+ Git.HomePath = filepath.Join(AppDataPath, Git.HomePath)
+ } else {
+ Git.HomePath = filepath.Clean(Git.HomePath)
+ }
+}
diff --git a/modules/setting/git_test.go b/modules/setting/git_test.go
new file mode 100644
index 0000000..34427f9
--- /dev/null
+++ b/modules/setting/git_test.go
@@ -0,0 +1,66 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitConfig(t *testing.T) {
+ oldGit := Git
+ oldGitConfig := GitConfig
+ defer func() {
+ Git = oldGit
+ GitConfig = oldGitConfig
+ }()
+
+ cfg, err := NewConfigProviderFromData(`
+[git.config]
+a.b = 1
+`)
+ require.NoError(t, err)
+ loadGitFrom(cfg)
+ assert.EqualValues(t, "1", GitConfig.Options["a.b"])
+ assert.EqualValues(t, "histogram", GitConfig.Options["diff.algorithm"])
+
+ cfg, err = NewConfigProviderFromData(`
+[git.config]
+diff.algorithm = other
+`)
+ require.NoError(t, err)
+ loadGitFrom(cfg)
+ assert.EqualValues(t, "other", GitConfig.Options["diff.algorithm"])
+}
+
+func TestGitReflog(t *testing.T) {
+ oldGit := Git
+ oldGitConfig := GitConfig
+ defer func() {
+ Git = oldGit
+ GitConfig = oldGitConfig
+ }()
+
+ // default reflog config without legacy options
+ cfg, err := NewConfigProviderFromData(``)
+ require.NoError(t, err)
+ loadGitFrom(cfg)
+
+ assert.EqualValues(t, "true", GitConfig.GetOption("core.logAllRefUpdates"))
+ assert.EqualValues(t, "90", GitConfig.GetOption("gc.reflogExpire"))
+
+ // custom reflog config by legacy options
+ cfg, err = NewConfigProviderFromData(`
+[git.reflog]
+ENABLED = false
+EXPIRATION = 123
+`)
+ require.NoError(t, err)
+ loadGitFrom(cfg)
+
+ assert.EqualValues(t, "false", GitConfig.GetOption("core.logAllRefUpdates"))
+ assert.EqualValues(t, "123", GitConfig.GetOption("gc.reflogExpire"))
+}
diff --git a/modules/setting/highlight.go b/modules/setting/highlight.go
new file mode 100644
index 0000000..6291b08
--- /dev/null
+++ b/modules/setting/highlight.go
@@ -0,0 +1,17 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+func GetHighlightMapping() map[string]string {
+ highlightMapping := map[string]string{}
+ if CfgProvider == nil {
+ return highlightMapping
+ }
+
+ keys := CfgProvider.Section("highlight.mapping").Keys()
+ for _, key := range keys {
+ highlightMapping[key.Name()] = key.Value()
+ }
+ return highlightMapping
+}
diff --git a/modules/setting/i18n.go b/modules/setting/i18n.go
new file mode 100644
index 0000000..889e52b
--- /dev/null
+++ b/modules/setting/i18n.go
@@ -0,0 +1,68 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// defaultI18nLangNames must be a slice, we need the order
+var defaultI18nLangNames = []string{
+ "en-US", "English",
+ "zh-CN", "简体中文",
+ "zh-HK", "ç¹é«”中文(香港)",
+ "zh-TW", "ç¹é«”中文(å°ç£ï¼‰",
+ "de-DE", "Deutsch",
+ "fr-FR", "Français",
+ "nl-NL", "Nederlands",
+ "lv-LV", "Latviešu",
+ "ru-RU", "РуÑÑкий",
+ "uk-UA", "УкраїнÑька",
+ "ja-JP", "日本語",
+ "es-ES", "Español",
+ "pt-BR", "Português do Brasil",
+ "pt-PT", "Português de Portugal",
+ "pl-PL", "Polski",
+ "bg", "БългарÑки",
+ "it-IT", "Italiano",
+ "fi-FI", "Suomi",
+ "fil", "Filipino",
+ "eo", "Esperanto",
+ "tr-TR", "Türkçe",
+ "cs-CZ", "Čeština",
+ "sl", "SlovenÅ¡Äina",
+ "sv-SE", "Svenska",
+ "ko-KR", "한국어",
+ "el-GR", "Ελληνικά",
+ "fa-IR", "Ùارسی",
+ "hu-HU", "Magyar nyelv",
+ "id-ID", "Bahasa Indonesia",
+}
+
+func defaultI18nLangs() (res []string) {
+ for i := 0; i < len(defaultI18nLangNames); i += 2 {
+ res = append(res, defaultI18nLangNames[i])
+ }
+ return res
+}
+
+func defaultI18nNames() (res []string) {
+ for i := 0; i < len(defaultI18nLangNames); i += 2 {
+ res = append(res, defaultI18nLangNames[i+1])
+ }
+ return res
+}
+
+var (
+ // I18n settings
+ Langs []string
+ Names []string
+)
+
+func loadI18nFrom(rootCfg ConfigProvider) {
+ Langs = rootCfg.Section("i18n").Key("LANGS").Strings(",")
+ if len(Langs) == 0 {
+ Langs = defaultI18nLangs()
+ }
+ Names = rootCfg.Section("i18n").Key("NAMES").Strings(",")
+ if len(Names) == 0 {
+ Names = defaultI18nNames()
+ }
+}
diff --git a/modules/setting/incoming_email.go b/modules/setting/incoming_email.go
new file mode 100644
index 0000000..287e729
--- /dev/null
+++ b/modules/setting/incoming_email.go
@@ -0,0 +1,89 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/mail"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var IncomingEmail = struct {
+ Enabled bool
+ ReplyToAddress string
+ TokenPlaceholder string `ini:"-"`
+ Host string
+ Port int
+ UseTLS bool `ini:"USE_TLS"`
+ SkipTLSVerify bool `ini:"SKIP_TLS_VERIFY"`
+ Username string
+ Password string
+ Mailbox string
+ DeleteHandledMessage bool
+ MaximumMessageSize uint32
+}{
+ Mailbox: "INBOX",
+ DeleteHandledMessage: true,
+ TokenPlaceholder: "%{token}",
+ MaximumMessageSize: 10485760,
+}
+
+func loadIncomingEmailFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "email.incoming", &IncomingEmail)
+
+ if !IncomingEmail.Enabled {
+ return
+ }
+
+ // Handle aliases
+ sec := rootCfg.Section("email.incoming")
+ if sec.HasKey("USER") && !sec.HasKey("USERNAME") {
+ IncomingEmail.Username = sec.Key("USER").String()
+ }
+ if sec.HasKey("PASSWD") && !sec.HasKey("PASSWORD") {
+ IncomingEmail.Password = sec.Key("PASSWD").String()
+ }
+
+ // Infer Port if not set
+ if IncomingEmail.Port == 0 {
+ if IncomingEmail.UseTLS {
+ IncomingEmail.Port = 993
+ } else {
+ IncomingEmail.Port = 143
+ }
+ }
+
+ if err := checkReplyToAddress(); err != nil {
+ log.Fatal("Invalid incoming_mail.REPLY_TO_ADDRESS (%s): %v", IncomingEmail.ReplyToAddress, err)
+ }
+}
+
+func checkReplyToAddress() error {
+ parsed, err := mail.ParseAddress(IncomingEmail.ReplyToAddress)
+ if err != nil {
+ return err
+ }
+
+ if parsed.Name != "" {
+ return fmt.Errorf("name must not be set")
+ }
+
+ c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder)
+ switch c {
+ case 0:
+ return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
+ case 1:
+ default:
+ return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder)
+ }
+
+ parts := strings.Split(IncomingEmail.ReplyToAddress, "@")
+ if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) {
+ return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
+ }
+
+ return nil
+}
diff --git a/modules/setting/incoming_email_test.go b/modules/setting/incoming_email_test.go
new file mode 100644
index 0000000..0fdd44d
--- /dev/null
+++ b/modules/setting/incoming_email_test.go
@@ -0,0 +1,74 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_loadIncomingEmailFrom(t *testing.T) {
+ makeBaseConfig := func() (ConfigProvider, ConfigSection) {
+ cfg, _ := NewConfigProviderFromData("")
+ sec := cfg.Section("email.incoming")
+ sec.NewKey("ENABLED", "true")
+ sec.NewKey("REPLY_TO_ADDRESS", "forge+%{token}@example.com")
+
+ return cfg, sec
+ }
+ resetIncomingEmailPort := func() func() {
+ return func() {
+ IncomingEmail.Port = 0
+ }
+ }
+
+ t.Run("aliases", func(t *testing.T) {
+ cfg, sec := makeBaseConfig()
+ sec.NewKey("USER", "jane.doe@example.com")
+ sec.NewKey("PASSWD", "y0u'll n3v3r gUess th1S!!1")
+
+ loadIncomingEmailFrom(cfg)
+
+ assert.EqualValues(t, "jane.doe@example.com", IncomingEmail.Username)
+ assert.EqualValues(t, "y0u'll n3v3r gUess th1S!!1", IncomingEmail.Password)
+ })
+
+ t.Run("Port settings", func(t *testing.T) {
+ t.Run("no port, no tls", func(t *testing.T) {
+ defer resetIncomingEmailPort()()
+ cfg, sec := makeBaseConfig()
+
+ // False is the default, but we test it explicitly.
+ sec.NewKey("USE_TLS", "false")
+
+ loadIncomingEmailFrom(cfg)
+
+ assert.EqualValues(t, 143, IncomingEmail.Port)
+ })
+
+ t.Run("no port, with tls", func(t *testing.T) {
+ defer resetIncomingEmailPort()()
+ cfg, sec := makeBaseConfig()
+
+ sec.NewKey("USE_TLS", "true")
+
+ loadIncomingEmailFrom(cfg)
+
+ assert.EqualValues(t, 993, IncomingEmail.Port)
+ })
+
+ t.Run("port overrides tls", func(t *testing.T) {
+ defer resetIncomingEmailPort()()
+ cfg, sec := makeBaseConfig()
+
+ sec.NewKey("PORT", "1993")
+ sec.NewKey("USE_TLS", "true")
+
+ loadIncomingEmailFrom(cfg)
+
+ assert.EqualValues(t, 1993, IncomingEmail.Port)
+ })
+ })
+}
diff --git a/modules/setting/indexer.go b/modules/setting/indexer.go
new file mode 100644
index 0000000..3c96b58
--- /dev/null
+++ b/modules/setting/indexer.go
@@ -0,0 +1,119 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/gobwas/glob"
+)
+
+// Indexer settings
+var Indexer = struct {
+ IssueType string
+ IssuePath string
+ IssueConnStr string
+ IssueConnAuth string
+ IssueIndexerName string
+ StartupTimeout time.Duration
+
+ RepoIndexerEnabled bool
+ RepoIndexerRepoTypes []string
+ RepoType string
+ RepoPath string
+ RepoConnStr string
+ RepoIndexerName string
+ MaxIndexerFileSize int64
+ IncludePatterns []Glob
+ ExcludePatterns []Glob
+ ExcludeVendored bool
+}{
+ IssueType: "bleve",
+ IssuePath: "indexers/issues.bleve",
+ IssueConnStr: "",
+ IssueConnAuth: "",
+ IssueIndexerName: "gitea_issues",
+
+ RepoIndexerEnabled: false,
+ RepoIndexerRepoTypes: []string{"sources", "forks", "mirrors", "templates"},
+ RepoType: "bleve",
+ RepoPath: "indexers/repos.bleve",
+ RepoConnStr: "",
+ RepoIndexerName: "gitea_codes",
+ MaxIndexerFileSize: 1024 * 1024,
+ ExcludeVendored: true,
+}
+
+type Glob struct {
+ glob glob.Glob
+ pattern string
+}
+
+func (g *Glob) Match(s string) bool {
+ return g.glob.Match(s)
+}
+
+func (g *Glob) Pattern() string {
+ return g.pattern
+}
+
+func loadIndexerFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("indexer")
+ Indexer.IssueType = sec.Key("ISSUE_INDEXER_TYPE").MustString("bleve")
+ Indexer.IssuePath = filepath.ToSlash(sec.Key("ISSUE_INDEXER_PATH").MustString(filepath.ToSlash(filepath.Join(AppDataPath, "indexers/issues.bleve"))))
+ if !filepath.IsAbs(Indexer.IssuePath) {
+ Indexer.IssuePath = filepath.ToSlash(filepath.Join(AppWorkPath, Indexer.IssuePath))
+ }
+ Indexer.IssueConnStr = sec.Key("ISSUE_INDEXER_CONN_STR").MustString(Indexer.IssueConnStr)
+
+ if Indexer.IssueType == "meilisearch" {
+ u, err := url.Parse(Indexer.IssueConnStr)
+ if err != nil {
+ log.Warn("Failed to parse ISSUE_INDEXER_CONN_STR: %v", err)
+ u = &url.URL{}
+ }
+ Indexer.IssueConnAuth, _ = u.User.Password()
+ u.User = nil
+ Indexer.IssueConnStr = u.String()
+ }
+
+ Indexer.IssueIndexerName = sec.Key("ISSUE_INDEXER_NAME").MustString(Indexer.IssueIndexerName)
+
+ Indexer.RepoIndexerEnabled = sec.Key("REPO_INDEXER_ENABLED").MustBool(false)
+ Indexer.RepoIndexerRepoTypes = strings.Split(sec.Key("REPO_INDEXER_REPO_TYPES").MustString("sources,forks,mirrors,templates"), ",")
+ Indexer.RepoType = sec.Key("REPO_INDEXER_TYPE").MustString("bleve")
+ Indexer.RepoPath = filepath.ToSlash(sec.Key("REPO_INDEXER_PATH").MustString(filepath.ToSlash(filepath.Join(AppDataPath, "indexers/repos.bleve"))))
+ if !filepath.IsAbs(Indexer.RepoPath) {
+ Indexer.RepoPath = filepath.ToSlash(filepath.Join(AppWorkPath, Indexer.RepoPath))
+ }
+ Indexer.RepoConnStr = sec.Key("REPO_INDEXER_CONN_STR").MustString("")
+ Indexer.RepoIndexerName = sec.Key("REPO_INDEXER_NAME").MustString("gitea_codes")
+
+ Indexer.IncludePatterns = IndexerGlobFromString(sec.Key("REPO_INDEXER_INCLUDE").MustString(""))
+ Indexer.ExcludePatterns = IndexerGlobFromString(sec.Key("REPO_INDEXER_EXCLUDE").MustString(""))
+ Indexer.ExcludeVendored = sec.Key("REPO_INDEXER_EXCLUDE_VENDORED").MustBool(true)
+ Indexer.MaxIndexerFileSize = sec.Key("MAX_FILE_SIZE").MustInt64(1024 * 1024)
+ Indexer.StartupTimeout = sec.Key("STARTUP_TIMEOUT").MustDuration(30 * time.Second)
+}
+
+// IndexerGlobFromString parses a comma separated list of patterns and returns a glob.Glob slice suited for repo indexing
+func IndexerGlobFromString(globstr string) []Glob {
+ extarr := make([]Glob, 0, 10)
+ for _, expr := range strings.Split(strings.ToLower(globstr), ",") {
+ expr = strings.TrimSpace(expr)
+ if expr != "" {
+ if g, err := glob.Compile(expr, '.', '/'); err != nil {
+ log.Info("Invalid glob expression '%s' (skipped): %v", expr, err)
+ } else {
+ extarr = append(extarr, Glob{glob: g, pattern: expr})
+ }
+ }
+ }
+ return extarr
+}
diff --git a/modules/setting/indexer_test.go b/modules/setting/indexer_test.go
new file mode 100644
index 0000000..8f0437b
--- /dev/null
+++ b/modules/setting/indexer_test.go
@@ -0,0 +1,71 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type indexerMatchList struct {
+ value string
+ position int
+}
+
+func Test_newIndexerGlobSettings(t *testing.T) {
+ checkGlobMatch(t, "", []indexerMatchList{})
+ checkGlobMatch(t, " ", []indexerMatchList{})
+ checkGlobMatch(t, "data, */data, */data/*, **/data/*, **/data/**", []indexerMatchList{
+ {"", -1},
+ {"don't", -1},
+ {"data", 0},
+ {"/data", 1},
+ {"x/data", 1},
+ {"x/data/y", 2},
+ {"a/b/c/data/z", 3},
+ {"a/b/c/data/x/y/z", 4},
+ })
+ checkGlobMatch(t, "*.txt, txt, **.txt, **txt, **txt*", []indexerMatchList{
+ {"my.txt", 0},
+ {"don't", -1},
+ {"mytxt", 3},
+ {"/data/my.txt", 2},
+ {"data/my.txt", 2},
+ {"data/txt", 3},
+ {"data/thistxtfile", 4},
+ {"/data/thistxtfile", 4},
+ })
+ checkGlobMatch(t, "data/**/*.txt, data/**.txt", []indexerMatchList{
+ {"data/a/b/c/d.txt", 0},
+ {"data/a.txt", 1},
+ })
+ checkGlobMatch(t, "**/*.txt, data/**.txt", []indexerMatchList{
+ {"data/a/b/c/d.txt", 0},
+ {"data/a.txt", 0},
+ {"a.txt", -1},
+ })
+}
+
+func checkGlobMatch(t *testing.T, globstr string, list []indexerMatchList) {
+ glist := IndexerGlobFromString(globstr)
+ if len(list) == 0 {
+ assert.Empty(t, glist)
+ return
+ }
+ assert.NotEmpty(t, glist)
+ for _, m := range list {
+ found := false
+ for pos, g := range glist {
+ if g.Match(m.value) {
+ assert.Equal(t, m.position, pos, "Test string `%s` doesn't match `%s`@%d, but matches @%d", m.value, globstr, m.position, pos)
+ found = true
+ break
+ }
+ }
+ if !found {
+ assert.Equal(t, m.position, -1, "Test string `%s` doesn't match `%s` anywhere; expected @%d", m.value, globstr, m.position)
+ }
+ }
+}
diff --git a/modules/setting/lfs.go b/modules/setting/lfs.go
new file mode 100644
index 0000000..7501017
--- /dev/null
+++ b/modules/setting/lfs.go
@@ -0,0 +1,82 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/generate"
+)
+
+// LFS represents the configuration for Git LFS
+var LFS = struct {
+ StartServer bool `ini:"LFS_START_SERVER"`
+ JWTSecretBytes []byte `ini:"-"`
+ HTTPAuthExpiry time.Duration `ini:"LFS_HTTP_AUTH_EXPIRY"`
+ MaxFileSize int64 `ini:"LFS_MAX_FILE_SIZE"`
+ LocksPagingNum int `ini:"LFS_LOCKS_PAGING_NUM"`
+
+ Storage *Storage
+}{}
+
+func loadLFSFrom(rootCfg ConfigProvider) error {
+ sec := rootCfg.Section("server")
+ if err := sec.MapTo(&LFS); err != nil {
+ return fmt.Errorf("failed to map LFS settings: %v", err)
+ }
+
+ lfsSec, _ := rootCfg.GetSection("lfs")
+
+ // Specifically default PATH to LFS_CONTENT_PATH
+ // DEPRECATED should not be removed because users maybe upgrade from lower version to the latest version
+ // if these are removed, the warning will not be shown
+ deprecatedSetting(rootCfg, "server", "LFS_CONTENT_PATH", "lfs", "PATH", "v1.19.0")
+
+ if val := sec.Key("LFS_CONTENT_PATH").String(); val != "" {
+ if lfsSec == nil {
+ lfsSec = rootCfg.Section("lfs")
+ }
+ lfsSec.Key("PATH").MustString(val)
+ }
+
+ var err error
+ LFS.Storage, err = getStorage(rootCfg, "lfs", "", lfsSec)
+ if err != nil {
+ return err
+ }
+
+ // Rest of LFS service settings
+ if LFS.LocksPagingNum == 0 {
+ LFS.LocksPagingNum = 50
+ }
+
+ LFS.HTTPAuthExpiry = sec.Key("LFS_HTTP_AUTH_EXPIRY").MustDuration(24 * time.Hour)
+
+ if !LFS.StartServer || !InstallLock {
+ return nil
+ }
+
+ jwtSecretBase64 := loadSecret(rootCfg.Section("server"), "LFS_JWT_SECRET_URI", "LFS_JWT_SECRET")
+ LFS.JWTSecretBytes, err = generate.DecodeJwtSecret(jwtSecretBase64)
+ if err != nil {
+ LFS.JWTSecretBytes, jwtSecretBase64, err = generate.NewJwtSecret()
+ if err != nil {
+ return fmt.Errorf("error generating JWT Secret for custom config: %v", err)
+ }
+
+ // Save secret
+ saveCfg, err := rootCfg.PrepareSaving()
+ if err != nil {
+ return fmt.Errorf("error saving JWT Secret for custom config: %v", err)
+ }
+ rootCfg.Section("server").Key("LFS_JWT_SECRET").SetValue(jwtSecretBase64)
+ saveCfg.Section("server").Key("LFS_JWT_SECRET").SetValue(jwtSecretBase64)
+ if err := saveCfg.Save(); err != nil {
+ return fmt.Errorf("error saving JWT Secret for custom config: %v", err)
+ }
+ }
+
+ return nil
+}
diff --git a/modules/setting/lfs_test.go b/modules/setting/lfs_test.go
new file mode 100644
index 0000000..c7f1637
--- /dev/null
+++ b/modules/setting/lfs_test.go
@@ -0,0 +1,102 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getStorageInheritNameSectionTypeForLFS(t *testing.T) {
+ iniStr := `
+ [storage]
+ STORAGE_TYPE = minio
+ `
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "minio", LFS.Storage.Type)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+
+ iniStr = `
+[server]
+LFS_CONTENT_PATH = path_ignored
+[lfs]
+PATH = path_used
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "local", LFS.Storage.Type)
+ assert.Contains(t, LFS.Storage.Path, "path_used")
+
+ iniStr = `
+[server]
+LFS_CONTENT_PATH = deprecatedpath
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "local", LFS.Storage.Type)
+ assert.Contains(t, LFS.Storage.Path, "deprecatedpath")
+
+ iniStr = `
+[storage.lfs]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "minio", LFS.Storage.Type)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+
+ iniStr = `
+[lfs]
+STORAGE_TYPE = my_minio
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "minio", LFS.Storage.Type)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+
+ iniStr = `
+[lfs]
+STORAGE_TYPE = my_minio
+MINIO_BASE_PATH = my_lfs/
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+
+ assert.EqualValues(t, "minio", LFS.Storage.Type)
+ assert.EqualValues(t, "my_lfs/", LFS.Storage.MinioConfig.BasePath)
+}
+
+func Test_LFSStorage1(t *testing.T) {
+ iniStr := `
+[storage]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadLFSFrom(cfg))
+ assert.EqualValues(t, "minio", LFS.Storage.Type)
+ assert.EqualValues(t, "gitea", LFS.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+}
diff --git a/modules/setting/log.go b/modules/setting/log.go
new file mode 100644
index 0000000..a141188
--- /dev/null
+++ b/modules/setting/log.go
@@ -0,0 +1,270 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ golog "log"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type LogGlobalConfig struct {
+ RootPath string
+
+ Mode string
+ Level log.Level
+ StacktraceLogLevel log.Level
+ BufferLen int
+
+ EnableSSHLog bool
+
+ AccessLogTemplate string
+ RequestIDHeaders []string
+}
+
+var Log LogGlobalConfig
+
+const accessLogTemplateDefault = `{{.Ctx.RemoteHost}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}" "{{.Ctx.Req.UserAgent}}"`
+
+func loadLogGlobalFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("log")
+
+ Log.Level = log.LevelFromString(sec.Key("LEVEL").MustString(log.INFO.String()))
+ Log.StacktraceLogLevel = log.LevelFromString(sec.Key("STACKTRACE_LEVEL").MustString(log.NONE.String()))
+ Log.BufferLen = sec.Key("BUFFER_LEN").MustInt(10000)
+ Log.Mode = sec.Key("MODE").MustString("console")
+
+ Log.RootPath = sec.Key("ROOT_PATH").MustString(path.Join(AppWorkPath, "log"))
+ if !filepath.IsAbs(Log.RootPath) {
+ Log.RootPath = filepath.Join(AppWorkPath, Log.RootPath)
+ }
+ Log.RootPath = util.FilePathJoinAbs(Log.RootPath)
+
+ Log.EnableSSHLog = sec.Key("ENABLE_SSH_LOG").MustBool(false)
+
+ Log.AccessLogTemplate = sec.Key("ACCESS_LOG_TEMPLATE").MustString(accessLogTemplateDefault)
+ Log.RequestIDHeaders = sec.Key("REQUEST_ID_HEADERS").Strings(",")
+}
+
+func prepareLoggerConfig(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("log")
+
+ if !sec.HasKey("logger.default.MODE") {
+ sec.Key("logger.default.MODE").MustString(",")
+ }
+
+ deprecatedSetting(rootCfg, "log", "ACCESS", "log", "logger.access.MODE", "1.21")
+ deprecatedSetting(rootCfg, "log", "ENABLE_ACCESS_LOG", "log", "logger.access.MODE", "1.21")
+ if val := sec.Key("ACCESS").String(); val != "" {
+ sec.Key("logger.access.MODE").MustString(val)
+ }
+ if sec.HasKey("ENABLE_ACCESS_LOG") && !sec.Key("ENABLE_ACCESS_LOG").MustBool() {
+ sec.Key("logger.access.MODE").SetValue("")
+ }
+
+ deprecatedSetting(rootCfg, "log", "ROUTER", "log", "logger.router.MODE", "1.21")
+ deprecatedSetting(rootCfg, "log", "DISABLE_ROUTER_LOG", "log", "logger.router.MODE", "1.21")
+ if val := sec.Key("ROUTER").String(); val != "" {
+ sec.Key("logger.router.MODE").MustString(val)
+ }
+ if !sec.HasKey("logger.router.MODE") {
+ sec.Key("logger.router.MODE").MustString(",") // use default logger
+ }
+ if sec.HasKey("DISABLE_ROUTER_LOG") && sec.Key("DISABLE_ROUTER_LOG").MustBool() {
+ sec.Key("logger.router.MODE").SetValue("")
+ }
+
+ deprecatedSetting(rootCfg, "log", "XORM", "log", "logger.xorm.MODE", "1.21")
+ deprecatedSetting(rootCfg, "log", "ENABLE_XORM_LOG", "log", "logger.xorm.MODE", "1.21")
+ if val := sec.Key("XORM").String(); val != "" {
+ sec.Key("logger.xorm.MODE").MustString(val)
+ }
+ if !sec.HasKey("logger.xorm.MODE") {
+ sec.Key("logger.xorm.MODE").MustString(",") // use default logger
+ }
+ if sec.HasKey("ENABLE_XORM_LOG") && !sec.Key("ENABLE_XORM_LOG").MustBool() {
+ sec.Key("logger.xorm.MODE").SetValue("")
+ }
+}
+
+func LogPrepareFilenameForWriter(fileName, defaultFileName string) string {
+ if fileName == "" {
+ fileName = defaultFileName
+ }
+ if !filepath.IsAbs(fileName) {
+ fileName = filepath.Join(Log.RootPath, fileName)
+ } else {
+ fileName = filepath.Clean(fileName)
+ }
+ if err := os.MkdirAll(filepath.Dir(fileName), os.ModePerm); err != nil {
+ panic(fmt.Sprintf("unable to create directory for log %q: %v", fileName, err.Error()))
+ }
+ return fileName
+}
+
+func loadLogModeByName(rootCfg ConfigProvider, loggerName, modeName string) (writerName, writerType string, writerMode log.WriterMode, err error) {
+ sec := rootCfg.Section("log." + modeName)
+
+ writerMode = log.WriterMode{}
+ writerType = ConfigSectionKeyString(sec, "MODE")
+ if writerType == "" {
+ writerType = modeName
+ }
+
+ writerName = modeName
+ defaultFlags := "stdflags"
+ defaultFilaName := "gitea.log"
+ if loggerName == "access" {
+ // "access" logger is special, by default it doesn't have output flags, so it also needs a new writer name to avoid conflicting with other writers.
+ // so "access" logger's writer name is usually "file.access" or "console.access"
+ writerName += ".access"
+ defaultFlags = "none"
+ defaultFilaName = "access.log"
+ }
+
+ writerMode.Level = log.LevelFromString(ConfigInheritedKeyString(sec, "LEVEL", Log.Level.String()))
+ writerMode.StacktraceLevel = log.LevelFromString(ConfigInheritedKeyString(sec, "STACKTRACE_LEVEL", Log.StacktraceLogLevel.String()))
+ writerMode.Prefix = ConfigInheritedKeyString(sec, "PREFIX")
+ writerMode.Expression = ConfigInheritedKeyString(sec, "EXPRESSION")
+ // flags are updated and set below
+
+ switch writerType {
+ case "console":
+ // if stderr is on journald, prefer stderr by default
+ useStderr := ConfigInheritedKey(sec, "STDERR").MustBool(log.JournaldOnStderr)
+ defaultCanColor := log.CanColorStdout
+ defaultJournald := log.JournaldOnStdout
+ if useStderr {
+ defaultCanColor = log.CanColorStderr
+ defaultJournald = log.JournaldOnStderr
+ }
+ writerOption := log.WriterConsoleOption{Stderr: useStderr}
+ writerMode.Colorize = ConfigInheritedKey(sec, "COLORIZE").MustBool(defaultCanColor)
+ writerMode.WriterOption = writerOption
+ // if we are ultimately on journald, update default flags
+ if defaultJournald {
+ defaultFlags = "journaldflags"
+ }
+ case "file":
+ fileName := LogPrepareFilenameForWriter(ConfigInheritedKey(sec, "FILE_NAME").String(), defaultFilaName)
+ writerOption := log.WriterFileOption{}
+ writerOption.FileName = fileName + filenameSuffix // FIXME: the suffix doesn't seem right, see its related comments
+ writerOption.LogRotate = ConfigInheritedKey(sec, "LOG_ROTATE").MustBool(true)
+ writerOption.MaxSize = 1 << uint(ConfigInheritedKey(sec, "MAX_SIZE_SHIFT").MustInt(28))
+ writerOption.DailyRotate = ConfigInheritedKey(sec, "DAILY_ROTATE").MustBool(true)
+ writerOption.MaxDays = ConfigInheritedKey(sec, "MAX_DAYS").MustInt(7)
+ writerOption.Compress = ConfigInheritedKey(sec, "COMPRESS").MustBool(true)
+ writerOption.CompressionLevel = ConfigInheritedKey(sec, "COMPRESSION_LEVEL").MustInt(-1)
+ writerMode.WriterOption = writerOption
+ case "conn":
+ writerOption := log.WriterConnOption{}
+ writerOption.ReconnectOnMsg = ConfigInheritedKey(sec, "RECONNECT_ON_MSG").MustBool()
+ writerOption.Reconnect = ConfigInheritedKey(sec, "RECONNECT").MustBool()
+ writerOption.Protocol = ConfigInheritedKey(sec, "PROTOCOL").In("tcp", []string{"tcp", "unix", "udp"})
+ writerOption.Addr = ConfigInheritedKey(sec, "ADDR").MustString(":7020")
+ writerMode.WriterOption = writerOption
+ default:
+ if !log.HasEventWriter(writerType) {
+ return "", "", writerMode, fmt.Errorf("invalid log writer type (mode): %s, maybe it needs something like 'MODE=file' in [log.%s] section", writerType, modeName)
+ }
+ }
+
+ // set flags last because the console writer code may update default flags
+ writerMode.Flags = log.FlagsFromString(ConfigInheritedKeyString(sec, "FLAGS", defaultFlags))
+
+ return writerName, writerType, writerMode, nil
+}
+
+var filenameSuffix = ""
+
+// RestartLogsWithPIDSuffix restarts the logs with a PID suffix on files
+// FIXME: it seems not right, it breaks log rotating or log collectors
+func RestartLogsWithPIDSuffix() {
+ filenameSuffix = fmt.Sprintf(".%d", os.Getpid())
+ initAllLoggers() // when forking, before restarting, rename logger file and re-init all loggers
+}
+
+func InitLoggersForTest() {
+ initAllLoggers()
+}
+
+// initAllLoggers creates all the log services
+func initAllLoggers() {
+ initManagedLoggers(log.GetManager(), CfgProvider)
+
+ golog.SetFlags(0)
+ golog.SetPrefix("")
+ golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info))
+}
+
+func initManagedLoggers(manager *log.LoggerManager, cfg ConfigProvider) {
+ loadLogGlobalFrom(cfg)
+ prepareLoggerConfig(cfg)
+
+ initLoggerByName(manager, cfg, log.DEFAULT) // default
+ initLoggerByName(manager, cfg, "access")
+ initLoggerByName(manager, cfg, "router")
+ initLoggerByName(manager, cfg, "xorm")
+}
+
+func initLoggerByName(manager *log.LoggerManager, rootCfg ConfigProvider, loggerName string) {
+ sec := rootCfg.Section("log")
+ keyPrefix := "logger." + loggerName
+
+ disabled := sec.HasKey(keyPrefix+".MODE") && sec.Key(keyPrefix+".MODE").String() == ""
+ if disabled {
+ return
+ }
+
+ modeVal := sec.Key(keyPrefix + ".MODE").String()
+ if modeVal == "," {
+ modeVal = Log.Mode
+ }
+
+ var eventWriters []log.EventWriter
+ modes := strings.Split(modeVal, ",")
+ for _, modeName := range modes {
+ modeName = strings.TrimSpace(modeName)
+ if modeName == "" {
+ continue
+ }
+ writerName, writerType, writerMode, err := loadLogModeByName(rootCfg, loggerName, modeName)
+ if err != nil {
+ log.FallbackErrorf("Failed to load writer mode %q for logger %s: %v", modeName, loggerName, err)
+ continue
+ }
+ if writerMode.BufferLen == 0 {
+ writerMode.BufferLen = Log.BufferLen
+ }
+ eventWriter := manager.GetSharedWriter(writerName)
+ if eventWriter == nil {
+ eventWriter, err = manager.NewSharedWriter(writerName, writerType, writerMode)
+ if err != nil {
+ log.FallbackErrorf("Failed to create event writer for logger %s: %v", loggerName, err)
+ continue
+ }
+ }
+ eventWriters = append(eventWriters, eventWriter)
+ }
+
+ manager.GetLogger(loggerName).ReplaceAllWriters(eventWriters...)
+}
+
+func InitSQLLoggersForCli(level log.Level) {
+ log.SetConsoleLogger("xorm", "console", level)
+}
+
+func IsAccessLogEnabled() bool {
+ return log.IsLoggerEnabled("access")
+}
+
+func IsRouteLogEnabled() bool {
+ return log.IsLoggerEnabled("router")
+}
diff --git a/modules/setting/log_test.go b/modules/setting/log_test.go
new file mode 100644
index 0000000..3134d3e
--- /dev/null
+++ b/modules/setting/log_test.go
@@ -0,0 +1,386 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/stretchr/testify/require"
+)
+
+func initLoggersByConfig(t *testing.T, config string) (*log.LoggerManager, func()) {
+ oldLogConfig := Log
+ Log = LogGlobalConfig{}
+ defer func() {
+ Log = oldLogConfig
+ }()
+
+ cfg, err := NewConfigProviderFromData(config)
+ require.NoError(t, err)
+
+ manager := log.NewManager()
+ initManagedLoggers(manager, cfg)
+ return manager, manager.Close
+}
+
+func toJSON(v any) string {
+ b, _ := json.MarshalIndent(v, "", "\t")
+ return string(b)
+}
+
+func TestLogConfigDefault(t *testing.T) {
+ manager, managerClose := initLoggersByConfig(t, ``)
+ defer managerClose()
+
+ writerDump := `
+{
+ "console": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": false
+ },
+ "WriterType": "console"
+ }
+}
+`
+
+ dump := manager.GetLogger(log.DEFAULT).DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+
+ dump = manager.GetLogger("router").DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("xorm").DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+}
+
+func TestLogConfigDisable(t *testing.T) {
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+logger.router.MODE =
+logger.xorm.MODE =
+`)
+ defer managerClose()
+
+ writerDump := `
+{
+ "console": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": false
+ },
+ "WriterType": "console"
+ }
+}
+`
+
+ dump := manager.GetLogger(log.DEFAULT).DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+
+ dump = manager.GetLogger("router").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+
+ dump = manager.GetLogger("xorm").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+}
+
+func TestLogConfigLegacyDefault(t *testing.T) {
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+MODE = console
+`)
+ defer managerClose()
+
+ writerDump := `
+{
+ "console": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": false
+ },
+ "WriterType": "console"
+ }
+}
+`
+
+ dump := manager.GetLogger(log.DEFAULT).DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+
+ dump = manager.GetLogger("router").DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("xorm").DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+}
+
+func TestLogConfigLegacyMode(t *testing.T) {
+ tempDir := t.TempDir()
+
+ tempPath := func(file string) string {
+ return filepath.Join(tempDir, file)
+ }
+
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+ROOT_PATH = `+tempDir+`
+MODE = file
+ROUTER = file
+ACCESS = file
+`)
+ defer managerClose()
+
+ writerDump := `
+{
+ "file": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Compress": true,
+ "CompressionLevel": -1,
+ "DailyRotate": true,
+ "FileName": "$FILENAME",
+ "LogRotate": true,
+ "MaxDays": 7,
+ "MaxSize": 268435456
+ },
+ "WriterType": "file"
+ }
+}
+`
+ writerDumpAccess := `
+{
+ "file.access": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "none",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Compress": true,
+ "CompressionLevel": -1,
+ "DailyRotate": true,
+ "FileName": "$FILENAME",
+ "LogRotate": true,
+ "MaxDays": 7,
+ "MaxSize": 268435456
+ },
+ "WriterType": "file"
+ }
+}
+`
+ dump := manager.GetLogger(log.DEFAULT).DumpWriters()
+ require.JSONEq(t, strings.ReplaceAll(writerDump, "$FILENAME", tempPath("gitea.log")), toJSON(dump))
+
+ dump = manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, strings.ReplaceAll(writerDumpAccess, "$FILENAME", tempPath("access.log")), toJSON(dump))
+
+ dump = manager.GetLogger("router").DumpWriters()
+ require.JSONEq(t, strings.ReplaceAll(writerDump, "$FILENAME", tempPath("gitea.log")), toJSON(dump))
+}
+
+func TestLogConfigLegacyModeDisable(t *testing.T) {
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+ROUTER = file
+ACCESS = file
+DISABLE_ROUTER_LOG = true
+ENABLE_ACCESS_LOG = false
+`)
+ defer managerClose()
+
+ dump := manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+
+ dump = manager.GetLogger("router").DumpWriters()
+ require.JSONEq(t, "{}", toJSON(dump))
+}
+
+func TestLogConfigNewConfig(t *testing.T) {
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+logger.access.MODE = console
+logger.xorm.MODE = console, console-1
+
+[log.console]
+LEVEL = warn
+
+[log.console-1]
+MODE = console
+LEVEL = error
+STDERR = true
+`)
+ defer managerClose()
+
+ writerDump := `
+{
+ "console": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "warn",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": false
+ },
+ "WriterType": "console"
+ },
+ "console-1": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "error",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": true
+ },
+ "WriterType": "console"
+ }
+}
+`
+ writerDumpAccess := `
+{
+ "console.access": {
+ "BufferLen": 10000,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "none",
+ "Level": "warn",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Stderr": false
+ },
+ "WriterType": "console"
+ }
+}
+`
+ dump := manager.GetLogger("xorm").DumpWriters()
+ require.JSONEq(t, writerDump, toJSON(dump))
+
+ dump = manager.GetLogger("access").DumpWriters()
+ require.JSONEq(t, writerDumpAccess, toJSON(dump))
+}
+
+func TestLogConfigModeFile(t *testing.T) {
+ tempDir := t.TempDir()
+
+ tempPath := func(file string) string {
+ return filepath.Join(tempDir, file)
+ }
+
+ manager, managerClose := initLoggersByConfig(t, `
+[log]
+ROOT_PATH = `+tempDir+`
+BUFFER_LEN = 10
+MODE = file, file1
+
+[log.file1]
+MODE = file
+LEVEL = error
+STACKTRACE_LEVEL = fatal
+EXPRESSION = filter
+FLAGS = medfile
+PREFIX = "[Prefix] "
+FILE_NAME = file-xxx.log
+LOG_ROTATE = false
+MAX_SIZE_SHIFT = 1
+DAILY_ROTATE = false
+MAX_DAYS = 90
+COMPRESS = false
+COMPRESSION_LEVEL = 4
+`)
+ defer managerClose()
+
+ writerDump := `
+{
+ "file": {
+ "BufferLen": 10,
+ "Colorize": false,
+ "Expression": "",
+ "Flags": "stdflags",
+ "Level": "info",
+ "Prefix": "",
+ "StacktraceLevel": "none",
+ "WriterOption": {
+ "Compress": true,
+ "CompressionLevel": -1,
+ "DailyRotate": true,
+ "FileName": "$FILENAME-0",
+ "LogRotate": true,
+ "MaxDays": 7,
+ "MaxSize": 268435456
+ },
+ "WriterType": "file"
+ },
+ "file1": {
+ "BufferLen": 10,
+ "Colorize": false,
+ "Expression": "filter",
+ "Flags": "medfile",
+ "Level": "error",
+ "Prefix": "[Prefix] ",
+ "StacktraceLevel": "fatal",
+ "WriterOption": {
+ "Compress": false,
+ "CompressionLevel": 4,
+ "DailyRotate": false,
+ "FileName": "$FILENAME-1",
+ "LogRotate": false,
+ "MaxDays": 90,
+ "MaxSize": 2
+ },
+ "WriterType": "file"
+ }
+}
+`
+
+ dump := manager.GetLogger(log.DEFAULT).DumpWriters()
+ expected := writerDump
+ expected = strings.ReplaceAll(expected, "$FILENAME-0", tempPath("gitea.log"))
+ expected = strings.ReplaceAll(expected, "$FILENAME-1", tempPath("file-xxx.log"))
+ require.JSONEq(t, expected, toJSON(dump))
+}
diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go
new file mode 100644
index 0000000..136d932
--- /dev/null
+++ b/modules/setting/mailer.go
@@ -0,0 +1,309 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "context"
+ "net"
+ "net/mail"
+ "strings"
+ "text/template"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+
+ shellquote "github.com/kballard/go-shellquote"
+)
+
+// Mailer represents mail service.
+type Mailer struct {
+ // Mailer
+ Name string `ini:"NAME"`
+ From string `ini:"FROM"`
+ EnvelopeFrom string `ini:"ENVELOPE_FROM"`
+ OverrideEnvelopeFrom bool `ini:"-"`
+ FromName string `ini:"-"`
+ FromEmail string `ini:"-"`
+ SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"`
+ SubjectPrefix string `ini:"SUBJECT_PREFIX"`
+ OverrideHeader map[string][]string `ini:"-"`
+
+ // SMTP sender
+ Protocol string `ini:"PROTOCOL"`
+ SMTPAddr string `ini:"SMTP_ADDR"`
+ SMTPPort string `ini:"SMTP_PORT"`
+ User string `ini:"USER"`
+ Passwd string `ini:"PASSWD"`
+ EnableHelo bool `ini:"ENABLE_HELO"`
+ HeloHostname string `ini:"HELO_HOSTNAME"`
+ ForceTrustServerCert bool `ini:"FORCE_TRUST_SERVER_CERT"`
+ UseClientCert bool `ini:"USE_CLIENT_CERT"`
+ ClientCertFile string `ini:"CLIENT_CERT_FILE"`
+ ClientKeyFile string `ini:"CLIENT_KEY_FILE"`
+
+ // Sendmail sender
+ SendmailPath string `ini:"SENDMAIL_PATH"`
+ SendmailArgs []string `ini:"-"`
+ SendmailTimeout time.Duration `ini:"SENDMAIL_TIMEOUT"`
+ SendmailConvertCRLF bool `ini:"SENDMAIL_CONVERT_CRLF"`
+
+ // Customization
+ FromDisplayNameFormat string `ini:"FROM_DISPLAY_NAME_FORMAT"`
+ FromDisplayNameFormatTemplate *template.Template `ini:"-"`
+}
+
+// MailService the global mailer
+var MailService *Mailer
+
+func loadMailsFrom(rootCfg ConfigProvider) {
+ loadMailerFrom(rootCfg)
+ loadRegisterMailFrom(rootCfg)
+ loadNotifyMailFrom(rootCfg)
+ loadIncomingEmailFrom(rootCfg)
+}
+
+func loadMailerFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("mailer")
+ // Check mailer setting.
+ if !sec.Key("ENABLED").MustBool() {
+ return
+ }
+
+ // Handle Deprecations and map on to new configuration
+ // DEPRECATED should not be removed because users maybe upgrade from lower version to the latest version
+ // if these are removed, the warning will not be shown
+ deprecatedSetting(rootCfg, "mailer", "MAILER_TYPE", "mailer", "PROTOCOL", "v1.19.0")
+ if sec.HasKey("MAILER_TYPE") && !sec.HasKey("PROTOCOL") {
+ if sec.Key("MAILER_TYPE").String() == "sendmail" {
+ sec.Key("PROTOCOL").MustString("sendmail")
+ }
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "HOST", "mailer", "SMTP_ADDR", "v1.19.0")
+ if sec.HasKey("HOST") && !sec.HasKey("SMTP_ADDR") {
+ givenHost := sec.Key("HOST").String()
+ addr, port, err := net.SplitHostPort(givenHost)
+ if err != nil && strings.Contains(err.Error(), "missing port in address") {
+ addr = givenHost
+ } else if err != nil {
+ log.Fatal("Invalid mailer.HOST (%s): %v", givenHost, err)
+ }
+ if addr == "" {
+ addr = "127.0.0.1"
+ }
+ sec.Key("SMTP_ADDR").MustString(addr)
+ sec.Key("SMTP_PORT").MustString(port)
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "IS_TLS_ENABLED", "mailer", "PROTOCOL", "v1.19.0")
+ if sec.HasKey("IS_TLS_ENABLED") && !sec.HasKey("PROTOCOL") {
+ if sec.Key("IS_TLS_ENABLED").MustBool() {
+ sec.Key("PROTOCOL").MustString("smtps")
+ } else {
+ sec.Key("PROTOCOL").MustString("smtp+starttls")
+ }
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "DISABLE_HELO", "mailer", "ENABLE_HELO", "v1.19.0")
+ if sec.HasKey("DISABLE_HELO") && !sec.HasKey("ENABLE_HELO") {
+ sec.Key("ENABLE_HELO").MustBool(!sec.Key("DISABLE_HELO").MustBool())
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "SKIP_VERIFY", "mailer", "FORCE_TRUST_SERVER_CERT", "v1.19.0")
+ if sec.HasKey("SKIP_VERIFY") && !sec.HasKey("FORCE_TRUST_SERVER_CERT") {
+ sec.Key("FORCE_TRUST_SERVER_CERT").MustBool(sec.Key("SKIP_VERIFY").MustBool())
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "USE_CERTIFICATE", "mailer", "USE_CLIENT_CERT", "v1.19.0")
+ if sec.HasKey("USE_CERTIFICATE") && !sec.HasKey("USE_CLIENT_CERT") {
+ sec.Key("USE_CLIENT_CERT").MustBool(sec.Key("USE_CERTIFICATE").MustBool())
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "CERT_FILE", "mailer", "CLIENT_CERT_FILE", "v1.19.0")
+ if sec.HasKey("CERT_FILE") && !sec.HasKey("CLIENT_CERT_FILE") {
+ sec.Key("CERT_FILE").MustString(sec.Key("CERT_FILE").String())
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "KEY_FILE", "mailer", "CLIENT_KEY_FILE", "v1.19.0")
+ if sec.HasKey("KEY_FILE") && !sec.HasKey("CLIENT_KEY_FILE") {
+ sec.Key("KEY_FILE").MustString(sec.Key("KEY_FILE").String())
+ }
+
+ deprecatedSetting(rootCfg, "mailer", "ENABLE_HTML_ALTERNATIVE", "mailer", "SEND_AS_PLAIN_TEXT", "v1.19.0")
+ if sec.HasKey("ENABLE_HTML_ALTERNATIVE") && !sec.HasKey("SEND_AS_PLAIN_TEXT") {
+ sec.Key("SEND_AS_PLAIN_TEXT").MustBool(!sec.Key("ENABLE_HTML_ALTERNATIVE").MustBool(false))
+ }
+
+ if sec.HasKey("PROTOCOL") && sec.Key("PROTOCOL").String() == "smtp+startls" {
+ log.Error("Deprecated fallback `[mailer]` `PROTOCOL = smtp+startls` present. Use `[mailer]` `PROTOCOL = smtp+starttls`` instead. This fallback will be removed in v1.19.0")
+ sec.Key("PROTOCOL").SetValue("smtp+starttls")
+ }
+
+ // Handle aliases
+ if sec.HasKey("USERNAME") && !sec.HasKey("USER") {
+ sec.Key("USER").SetValue(sec.Key("USERNAME").String())
+ }
+ if sec.HasKey("PASSWORD") && !sec.HasKey("PASSWD") {
+ sec.Key("PASSWD").SetValue(sec.Key("PASSWORD").String())
+ }
+
+ // Set default values & validate
+ sec.Key("NAME").MustString(AppName)
+ sec.Key("PROTOCOL").In("", []string{"smtp", "smtps", "smtp+starttls", "smtp+unix", "sendmail", "dummy"})
+ sec.Key("ENABLE_HELO").MustBool(true)
+ sec.Key("FORCE_TRUST_SERVER_CERT").MustBool(false)
+ sec.Key("USE_CLIENT_CERT").MustBool(false)
+ sec.Key("SENDMAIL_PATH").MustString("sendmail")
+ sec.Key("SENDMAIL_TIMEOUT").MustDuration(5 * time.Minute)
+ sec.Key("SENDMAIL_CONVERT_CRLF").MustBool(true)
+ sec.Key("FROM").MustString(sec.Key("USER").String())
+
+ // Now map the values on to the MailService
+ MailService = &Mailer{}
+ if err := sec.MapTo(MailService); err != nil {
+ log.Fatal("Unable to map [mailer] section on to MailService. Error: %v", err)
+ }
+
+ overrideHeader := rootCfg.Section("mailer.override_header").Keys()
+ MailService.OverrideHeader = make(map[string][]string)
+ for _, key := range overrideHeader {
+ MailService.OverrideHeader[key.Name()] = key.Strings(",")
+ }
+
+ // Infer SMTPPort if not set
+ if MailService.SMTPPort == "" {
+ switch MailService.Protocol {
+ case "smtp":
+ MailService.SMTPPort = "25"
+ case "smtps":
+ MailService.SMTPPort = "465"
+ case "smtp+starttls":
+ MailService.SMTPPort = "587"
+ }
+ }
+
+ // Infer Protocol
+ if MailService.Protocol == "" {
+ if strings.ContainsAny(MailService.SMTPAddr, "/\\") {
+ MailService.Protocol = "smtp+unix"
+ } else {
+ switch MailService.SMTPPort {
+ case "25":
+ MailService.Protocol = "smtp"
+ case "465":
+ MailService.Protocol = "smtps"
+ case "587":
+ MailService.Protocol = "smtp+starttls"
+ default:
+ log.Error("unable to infer unspecified mailer.PROTOCOL from mailer.SMTP_PORT = %q, assume using smtps", MailService.SMTPPort)
+ MailService.Protocol = "smtps"
+ if MailService.SMTPPort == "" {
+ MailService.SMTPPort = "465"
+ }
+ }
+ }
+ }
+
+ // we want to warn if users use SMTP on a non-local IP;
+ // we might as well take the opportunity to check that it has an IP at all
+ // This check is not needed for sendmail
+ switch MailService.Protocol {
+ case "sendmail":
+ var err error
+ MailService.SendmailArgs, err = shellquote.Split(sec.Key("SENDMAIL_ARGS").String())
+ if err != nil {
+ log.Error("Failed to parse Sendmail args: '%s' with error %v", sec.Key("SENDMAIL_ARGS").String(), err)
+ }
+ case "smtp", "smtps", "smtp+starttls", "smtp+unix":
+ ips := tryResolveAddr(MailService.SMTPAddr)
+ if MailService.Protocol == "smtp" {
+ for _, ip := range ips {
+ if !ip.IP.IsLoopback() {
+ log.Warn("connecting over insecure SMTP protocol to non-local address is not recommended")
+ break
+ }
+ }
+ }
+ case "dummy": // just mention and do nothing
+ }
+
+ if MailService.From != "" {
+ parsed, err := mail.ParseAddress(MailService.From)
+ if err != nil {
+ log.Fatal("Invalid mailer.FROM (%s): %v", MailService.From, err)
+ }
+ MailService.FromName = parsed.Name
+ MailService.FromEmail = parsed.Address
+ } else {
+ log.Error("no mailer.FROM provided, email system may not work.")
+ }
+
+ MailService.FromDisplayNameFormatTemplate, _ = template.New("mailFrom").Parse("{{ .DisplayName }}")
+ if MailService.FromDisplayNameFormat != "" {
+ template, err := template.New("mailFrom").Parse(MailService.FromDisplayNameFormat)
+ if err != nil {
+ log.Error("mailer.FROM_DISPLAY_NAME_FORMAT is no valid template: %v", err)
+ } else {
+ MailService.FromDisplayNameFormatTemplate = template
+ }
+ }
+
+ switch MailService.EnvelopeFrom {
+ case "":
+ MailService.OverrideEnvelopeFrom = false
+ case "<>":
+ MailService.EnvelopeFrom = ""
+ MailService.OverrideEnvelopeFrom = true
+ default:
+ parsed, err := mail.ParseAddress(MailService.EnvelopeFrom)
+ if err != nil {
+ log.Fatal("Invalid mailer.ENVELOPE_FROM (%s): %v", MailService.EnvelopeFrom, err)
+ }
+ MailService.OverrideEnvelopeFrom = true
+ MailService.EnvelopeFrom = parsed.Address
+ }
+
+ log.Info("Mail Service Enabled")
+}
+
+func loadRegisterMailFrom(rootCfg ConfigProvider) {
+ if !rootCfg.Section("service").Key("REGISTER_EMAIL_CONFIRM").MustBool() {
+ return
+ } else if MailService == nil {
+ log.Warn("Register Mail Service: Mail Service is not enabled")
+ return
+ }
+ Service.RegisterEmailConfirm = true
+ log.Info("Register Mail Service Enabled")
+}
+
+func loadNotifyMailFrom(rootCfg ConfigProvider) {
+ if !rootCfg.Section("service").Key("ENABLE_NOTIFY_MAIL").MustBool() {
+ return
+ } else if MailService == nil {
+ log.Warn("Notify Mail Service: Mail Service is not enabled")
+ return
+ }
+ Service.EnableNotifyMail = true
+ log.Info("Notify Mail Service Enabled")
+}
+
+func tryResolveAddr(addr string) []net.IPAddr {
+ if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
+ addr = addr[1 : len(addr)-1]
+ }
+ ip := net.ParseIP(addr)
+ if ip != nil {
+ return []net.IPAddr{{IP: ip}}
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ ips, err := net.DefaultResolver.LookupIPAddr(ctx, addr)
+ if err != nil {
+ log.Warn("could not look up mailer.SMTP_ADDR: %v", err)
+ return nil
+ }
+ return ips
+}
diff --git a/modules/setting/mailer_test.go b/modules/setting/mailer_test.go
new file mode 100644
index 0000000..f8af4a7
--- /dev/null
+++ b/modules/setting/mailer_test.go
@@ -0,0 +1,54 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_loadMailerFrom(t *testing.T) {
+ kases := map[string]*Mailer{
+ "smtp.mydomain.com": {
+ SMTPAddr: "smtp.mydomain.com",
+ SMTPPort: "465",
+ },
+ "smtp.mydomain.com:123": {
+ SMTPAddr: "smtp.mydomain.com",
+ SMTPPort: "123",
+ },
+ ":123": {
+ SMTPAddr: "127.0.0.1",
+ SMTPPort: "123",
+ },
+ }
+ for host, kase := range kases {
+ t.Run(host, func(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData("")
+ sec := cfg.Section("mailer")
+ sec.NewKey("ENABLED", "true")
+ sec.NewKey("HOST", host)
+
+ // Check mailer setting
+ loadMailerFrom(cfg)
+
+ assert.EqualValues(t, kase.SMTPAddr, MailService.SMTPAddr)
+ assert.EqualValues(t, kase.SMTPPort, MailService.SMTPPort)
+ })
+ }
+
+ t.Run("property aliases", func(t *testing.T) {
+ cfg, _ := NewConfigProviderFromData("")
+ sec := cfg.Section("mailer")
+ sec.NewKey("ENABLED", "true")
+ sec.NewKey("USERNAME", "jane.doe@example.com")
+ sec.NewKey("PASSWORD", "y0u'll n3v3r gUess th1S!!1")
+
+ loadMailerFrom(cfg)
+
+ assert.EqualValues(t, "jane.doe@example.com", MailService.User)
+ assert.EqualValues(t, "y0u'll n3v3r gUess th1S!!1", MailService.Passwd)
+ })
+}
diff --git a/modules/setting/markup.go b/modules/setting/markup.go
new file mode 100644
index 0000000..e893c1c
--- /dev/null
+++ b/modules/setting/markup.go
@@ -0,0 +1,192 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// ExternalMarkupRenderers represents the external markup renderers
+var (
+ ExternalMarkupRenderers []*MarkupRenderer
+ ExternalSanitizerRules []MarkupSanitizerRule
+ MermaidMaxSourceCharacters int
+ FilePreviewMaxLines int
+)
+
+const (
+ RenderContentModeSanitized = "sanitized"
+ RenderContentModeNoSanitizer = "no-sanitizer"
+ RenderContentModeIframe = "iframe"
+)
+
+// Markdown settings
+var Markdown = struct {
+ EnableHardLineBreakInComments bool
+ EnableHardLineBreakInDocuments bool
+ CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"`
+ FileExtensions []string
+ EnableMath bool
+}{
+ EnableHardLineBreakInComments: true,
+ EnableHardLineBreakInDocuments: false,
+ FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd,.livemd", ","),
+ EnableMath: true,
+}
+
+// MarkupRenderer defines the external parser configured in ini
+type MarkupRenderer struct {
+ Enabled bool
+ MarkupName string
+ Command string
+ FileExtensions []string
+ IsInputFile bool
+ NeedPostProcess bool
+ MarkupSanitizerRules []MarkupSanitizerRule
+ RenderContentMode string
+}
+
+// MarkupSanitizerRule defines the policy for whitelisting attributes on
+// certain elements.
+type MarkupSanitizerRule struct {
+ Element string
+ AllowAttr string
+ Regexp *regexp.Regexp
+ AllowDataURIImages bool
+}
+
+func loadMarkupFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "markdown", &Markdown)
+
+ MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000)
+ FilePreviewMaxLines = rootCfg.Section("markup").Key("FILEPREVIEW_MAX_LINES").MustInt(50)
+ ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
+ ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
+
+ for _, sec := range rootCfg.Section("markup").ChildSections() {
+ name := strings.TrimPrefix(sec.Name(), "markup.")
+ if name == "" {
+ log.Warn("name is empty, markup " + sec.Name() + "ignored")
+ continue
+ }
+
+ if name == "sanitizer" || strings.HasPrefix(name, "sanitizer.") {
+ newMarkupSanitizer(name, sec)
+ } else {
+ newMarkupRenderer(name, sec)
+ }
+ }
+}
+
+func newMarkupSanitizer(name string, sec ConfigSection) {
+ rule, ok := createMarkupSanitizerRule(name, sec)
+ if ok {
+ if strings.HasPrefix(name, "sanitizer.") {
+ names := strings.SplitN(strings.TrimPrefix(name, "sanitizer."), ".", 2)
+ name = names[0]
+ }
+ for _, renderer := range ExternalMarkupRenderers {
+ if name == renderer.MarkupName {
+ renderer.MarkupSanitizerRules = append(renderer.MarkupSanitizerRules, rule)
+ return
+ }
+ }
+ ExternalSanitizerRules = append(ExternalSanitizerRules, rule)
+ }
+}
+
+func createMarkupSanitizerRule(name string, sec ConfigSection) (MarkupSanitizerRule, bool) {
+ var rule MarkupSanitizerRule
+
+ ok := false
+ if sec.HasKey("ALLOW_DATA_URI_IMAGES") {
+ rule.AllowDataURIImages = sec.Key("ALLOW_DATA_URI_IMAGES").MustBool(false)
+ ok = true
+ }
+
+ if sec.HasKey("ELEMENT") || sec.HasKey("ALLOW_ATTR") {
+ rule.Element = sec.Key("ELEMENT").Value()
+ rule.AllowAttr = sec.Key("ALLOW_ATTR").Value()
+
+ if rule.Element == "" || rule.AllowAttr == "" {
+ log.Error("Missing required values from markup.%s. Must have ELEMENT and ALLOW_ATTR defined!", name)
+ return rule, false
+ }
+
+ regexpStr := sec.Key("REGEXP").Value()
+ if regexpStr != "" {
+ // Validate when parsing the config that this is a valid regular
+ // expression. Then we can use regexp.MustCompile(...) later.
+ compiled, err := regexp.Compile(regexpStr)
+ if err != nil {
+ log.Error("In markup.%s: REGEXP (%s) failed to compile: %v", name, regexpStr, err)
+ return rule, false
+ }
+
+ rule.Regexp = compiled
+ }
+
+ ok = true
+ }
+
+ if !ok {
+ log.Error("Missing required keys from markup.%s. Must have ELEMENT and ALLOW_ATTR or ALLOW_DATA_URI_IMAGES defined!", name)
+ return rule, false
+ }
+
+ return rule, true
+}
+
+func newMarkupRenderer(name string, sec ConfigSection) {
+ extensionReg := regexp.MustCompile(`\.\w`)
+
+ extensions := sec.Key("FILE_EXTENSIONS").Strings(",")
+ exts := make([]string, 0, len(extensions))
+ for _, extension := range extensions {
+ if !extensionReg.MatchString(extension) {
+ log.Warn(sec.Name() + " file extension " + extension + " is invalid. Extension ignored")
+ } else {
+ exts = append(exts, extension)
+ }
+ }
+
+ if len(exts) == 0 {
+ log.Warn(sec.Name() + " file extension is empty, markup " + name + " ignored")
+ return
+ }
+
+ command := sec.Key("RENDER_COMMAND").MustString("")
+ if command == "" {
+ log.Warn(" RENDER_COMMAND is empty, markup " + name + " ignored")
+ return
+ }
+
+ if sec.HasKey("DISABLE_SANITIZER") {
+ log.Error("Deprecated setting `[markup.*]` `DISABLE_SANITIZER` present. This fallback will be removed in v1.18.0")
+ }
+
+ renderContentMode := sec.Key("RENDER_CONTENT_MODE").MustString(RenderContentModeSanitized)
+ if !sec.HasKey("RENDER_CONTENT_MODE") && sec.Key("DISABLE_SANITIZER").MustBool(false) {
+ renderContentMode = RenderContentModeNoSanitizer // if only the legacy DISABLE_SANITIZER exists, use it
+ }
+ if renderContentMode != RenderContentModeSanitized &&
+ renderContentMode != RenderContentModeNoSanitizer &&
+ renderContentMode != RenderContentModeIframe {
+ log.Error("invalid RENDER_CONTENT_MODE: %q, default to %q", renderContentMode, RenderContentModeSanitized)
+ renderContentMode = RenderContentModeSanitized
+ }
+
+ ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
+ Enabled: sec.Key("ENABLED").MustBool(false),
+ MarkupName: name,
+ FileExtensions: exts,
+ Command: command,
+ IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
+ NeedPostProcess: sec.Key("NEED_POSTPROCESS").MustBool(true),
+ RenderContentMode: renderContentMode,
+ })
+}
diff --git a/modules/setting/metrics.go b/modules/setting/metrics.go
new file mode 100644
index 0000000..daa0e3b
--- /dev/null
+++ b/modules/setting/metrics.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Metrics settings
+var Metrics = struct {
+ Enabled bool
+ Token string
+ EnabledIssueByLabel bool
+ EnabledIssueByRepository bool
+}{
+ Enabled: false,
+ Token: "",
+ EnabledIssueByLabel: false,
+ EnabledIssueByRepository: false,
+}
+
+func loadMetricsFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "metrics", &Metrics)
+}
diff --git a/modules/setting/migrations.go b/modules/setting/migrations.go
new file mode 100644
index 0000000..5a6079b
--- /dev/null
+++ b/modules/setting/migrations.go
@@ -0,0 +1,28 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Migrations settings
+var Migrations = struct {
+ MaxAttempts int
+ RetryBackoff int
+ AllowedDomains string
+ BlockedDomains string
+ AllowLocalNetworks bool
+ SkipTLSVerify bool
+}{
+ MaxAttempts: 3,
+ RetryBackoff: 3,
+}
+
+func loadMigrationsFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("migrations")
+ Migrations.MaxAttempts = sec.Key("MAX_ATTEMPTS").MustInt(Migrations.MaxAttempts)
+ Migrations.RetryBackoff = sec.Key("RETRY_BACKOFF").MustInt(Migrations.RetryBackoff)
+
+ Migrations.AllowedDomains = sec.Key("ALLOWED_DOMAINS").MustString("")
+ Migrations.BlockedDomains = sec.Key("BLOCKED_DOMAINS").MustString("")
+ Migrations.AllowLocalNetworks = sec.Key("ALLOW_LOCALNETWORKS").MustBool(false)
+ Migrations.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool(false)
+}
diff --git a/modules/setting/mime_type_map.go b/modules/setting/mime_type_map.go
new file mode 100644
index 0000000..55cb2c0
--- /dev/null
+++ b/modules/setting/mime_type_map.go
@@ -0,0 +1,28 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import "strings"
+
+// MimeTypeMap defines custom mime type mapping settings
+var MimeTypeMap = struct {
+ Enabled bool
+ Map map[string]string
+}{
+ Enabled: false,
+ Map: map[string]string{},
+}
+
+func loadMimeTypeMapFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("repository.mimetype_mapping")
+ keys := sec.Keys()
+ m := make(map[string]string, len(keys))
+ for _, key := range keys {
+ m[strings.ToLower(key.Name())] = key.Value()
+ }
+ MimeTypeMap.Map = m
+ if len(keys) > 0 {
+ MimeTypeMap.Enabled = true
+ }
+}
diff --git a/modules/setting/mirror.go b/modules/setting/mirror.go
new file mode 100644
index 0000000..3aa530a
--- /dev/null
+++ b/modules/setting/mirror.go
@@ -0,0 +1,58 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Mirror settings
+var Mirror = struct {
+ Enabled bool
+ DisableNewPull bool
+ DisableNewPush bool
+ DefaultInterval time.Duration
+ MinInterval time.Duration
+}{
+ Enabled: true,
+ DisableNewPull: false,
+ DisableNewPush: false,
+ MinInterval: 10 * time.Minute,
+ DefaultInterval: 8 * time.Hour,
+}
+
+func loadMirrorFrom(rootCfg ConfigProvider) {
+ // Handle old configuration through `[repository]` `DISABLE_MIRRORS`
+ // - please note this was badly named and only disabled the creation of new pull mirrors
+ // DEPRECATED should not be removed because users maybe upgrade from lower version to the latest version
+ // if these are removed, the warning will not be shown
+ deprecatedSetting(rootCfg, "repository", "DISABLE_MIRRORS", "mirror", "ENABLED", "v1.19.0")
+ if ConfigSectionKeyBool(rootCfg.Section("repository"), "DISABLE_MIRRORS") {
+ Mirror.DisableNewPull = true
+ }
+
+ if err := rootCfg.Section("mirror").MapTo(&Mirror); err != nil {
+ log.Fatal("Failed to map Mirror settings: %v", err)
+ }
+
+ if !Mirror.Enabled {
+ Mirror.DisableNewPull = true
+ Mirror.DisableNewPush = true
+ }
+
+ if Mirror.MinInterval.Minutes() < 1 {
+ log.Warn("Mirror.MinInterval is too low, set to 1 minute")
+ Mirror.MinInterval = 1 * time.Minute
+ }
+ if Mirror.DefaultInterval < Mirror.MinInterval {
+ if time.Hour*8 < Mirror.MinInterval {
+ Mirror.DefaultInterval = Mirror.MinInterval
+ } else {
+ Mirror.DefaultInterval = time.Hour * 8
+ }
+ log.Warn("Mirror.DefaultInterval is less than Mirror.MinInterval, set to %s", Mirror.DefaultInterval.String())
+ }
+}
diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go
new file mode 100644
index 0000000..49288e2
--- /dev/null
+++ b/modules/setting/oauth2.go
@@ -0,0 +1,174 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "math"
+ "path/filepath"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/generate"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// OAuth2UsernameType is enum describing the way gitea 'name' should be generated from oauth2 data
+type OAuth2UsernameType string
+
+const (
+ // OAuth2UsernameUserid oauth2 userid field will be used as gitea name
+ OAuth2UsernameUserid OAuth2UsernameType = "userid"
+ // OAuth2UsernameNickname oauth2 nickname field will be used as gitea name
+ OAuth2UsernameNickname OAuth2UsernameType = "nickname"
+ // OAuth2UsernameEmail username of oauth2 email field will be used as gitea name
+ OAuth2UsernameEmail OAuth2UsernameType = "email"
+)
+
+func (username OAuth2UsernameType) isValid() bool {
+ switch username {
+ case OAuth2UsernameUserid, OAuth2UsernameNickname, OAuth2UsernameEmail:
+ return true
+ }
+ return false
+}
+
+// OAuth2AccountLinkingType is enum describing behaviour of linking with existing account
+type OAuth2AccountLinkingType string
+
+const (
+ // OAuth2AccountLinkingDisabled error will be displayed if account exist
+ OAuth2AccountLinkingDisabled OAuth2AccountLinkingType = "disabled"
+ // OAuth2AccountLinkingLogin account linking login will be displayed if account exist
+ OAuth2AccountLinkingLogin OAuth2AccountLinkingType = "login"
+ // OAuth2AccountLinkingAuto account will be automatically linked if account exist
+ OAuth2AccountLinkingAuto OAuth2AccountLinkingType = "auto"
+)
+
+func (accountLinking OAuth2AccountLinkingType) isValid() bool {
+ switch accountLinking {
+ case OAuth2AccountLinkingDisabled, OAuth2AccountLinkingLogin, OAuth2AccountLinkingAuto:
+ return true
+ }
+ return false
+}
+
+// OAuth2Client settings
+var OAuth2Client struct {
+ RegisterEmailConfirm bool
+ OpenIDConnectScopes []string
+ EnableAutoRegistration bool
+ Username OAuth2UsernameType
+ UpdateAvatar bool
+ AccountLinking OAuth2AccountLinkingType
+}
+
+func loadOAuth2ClientFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("oauth2_client")
+ OAuth2Client.RegisterEmailConfirm = sec.Key("REGISTER_EMAIL_CONFIRM").MustBool(Service.RegisterEmailConfirm)
+ OAuth2Client.OpenIDConnectScopes = parseScopes(sec, "OPENID_CONNECT_SCOPES")
+ OAuth2Client.EnableAutoRegistration = sec.Key("ENABLE_AUTO_REGISTRATION").MustBool()
+ OAuth2Client.Username = OAuth2UsernameType(sec.Key("USERNAME").MustString(string(OAuth2UsernameNickname)))
+ if !OAuth2Client.Username.isValid() {
+ log.Warn("Username setting is not valid: '%s', will fallback to '%s'", OAuth2Client.Username, OAuth2UsernameNickname)
+ OAuth2Client.Username = OAuth2UsernameNickname
+ }
+ OAuth2Client.UpdateAvatar = sec.Key("UPDATE_AVATAR").MustBool()
+ OAuth2Client.AccountLinking = OAuth2AccountLinkingType(sec.Key("ACCOUNT_LINKING").MustString(string(OAuth2AccountLinkingLogin)))
+ if !OAuth2Client.AccountLinking.isValid() {
+ log.Warn("Account linking setting is not valid: '%s', will fallback to '%s'", OAuth2Client.AccountLinking, OAuth2AccountLinkingLogin)
+ OAuth2Client.AccountLinking = OAuth2AccountLinkingLogin
+ }
+}
+
+func parseScopes(sec ConfigSection, name string) []string {
+ parts := sec.Key(name).Strings(" ")
+ scopes := make([]string, 0, len(parts))
+ for _, scope := range parts {
+ if scope != "" {
+ scopes = append(scopes, scope)
+ }
+ }
+ return scopes
+}
+
+var OAuth2 = struct {
+ Enabled bool
+ AccessTokenExpirationTime int64
+ RefreshTokenExpirationTime int64
+ InvalidateRefreshTokens bool
+ JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"`
+ JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
+ MaxTokenLength int
+ DefaultApplications []string
+ EnableAdditionalGrantScopes bool
+}{
+ Enabled: true,
+ AccessTokenExpirationTime: 3600,
+ RefreshTokenExpirationTime: 730,
+ InvalidateRefreshTokens: true,
+ JWTSigningAlgorithm: "RS256",
+ JWTSigningPrivateKeyFile: "jwt/private.pem",
+ MaxTokenLength: math.MaxInt16,
+ DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
+ EnableAdditionalGrantScopes: false,
+}
+
+func loadOAuth2From(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("oauth2")
+ if err := sec.MapTo(&OAuth2); err != nil {
+ log.Fatal("Failed to map OAuth2 settings: %v", err)
+ return
+ }
+
+ // Handle the rename of ENABLE to ENABLED
+ deprecatedSetting(rootCfg, "oauth2", "ENABLE", "oauth2", "ENABLED", "v1.23.0")
+ if sec.HasKey("ENABLE") && !sec.HasKey("ENABLED") {
+ OAuth2.Enabled = sec.Key("ENABLE").MustBool(OAuth2.Enabled)
+ }
+
+ if !filepath.IsAbs(OAuth2.JWTSigningPrivateKeyFile) {
+ OAuth2.JWTSigningPrivateKeyFile = filepath.Join(AppDataPath, OAuth2.JWTSigningPrivateKeyFile)
+ }
+
+ // FIXME: at the moment, no matter oauth2 is enabled or not, it must generate a "oauth2 JWT_SECRET"
+ // Because this secret is also used as GeneralTokenSigningSecret (as a quick not-that-breaking fix for some legacy problems).
+ // Including: CSRF token, account validation token, etc ...
+ // In main branch, the signing token should be refactored (eg: one unique for LFS/OAuth2/etc ...)
+ jwtSecretBase64 := loadSecret(sec, "JWT_SECRET_URI", "JWT_SECRET")
+ if InstallLock {
+ jwtSecretBytes, err := generate.DecodeJwtSecret(jwtSecretBase64)
+ if err != nil {
+ jwtSecretBytes, jwtSecretBase64, err = generate.NewJwtSecret()
+ if err != nil {
+ log.Fatal("error generating JWT secret: %v", err)
+ }
+ saveCfg, err := rootCfg.PrepareSaving()
+ if err != nil {
+ log.Fatal("save oauth2.JWT_SECRET failed: %v", err)
+ }
+ rootCfg.Section("oauth2").Key("JWT_SECRET").SetValue(jwtSecretBase64)
+ saveCfg.Section("oauth2").Key("JWT_SECRET").SetValue(jwtSecretBase64)
+ if err := saveCfg.Save(); err != nil {
+ log.Fatal("save oauth2.JWT_SECRET failed: %v", err)
+ }
+ }
+ generalSigningSecret.Store(&jwtSecretBytes)
+ }
+}
+
+var generalSigningSecret atomic.Pointer[[]byte]
+
+func GetGeneralTokenSigningSecret() []byte {
+ old := generalSigningSecret.Load()
+ if old == nil || len(*old) == 0 {
+ jwtSecret, _, err := generate.NewJwtSecret()
+ if err != nil {
+ log.Fatal("Unable to generate general JWT secret: %v", err)
+ }
+ if generalSigningSecret.CompareAndSwap(old, &jwtSecret) {
+ return jwtSecret
+ }
+ return *generalSigningSecret.Load()
+ }
+ return *old
+}
diff --git a/modules/setting/oauth2_test.go b/modules/setting/oauth2_test.go
new file mode 100644
index 0000000..18252b2
--- /dev/null
+++ b/modules/setting/oauth2_test.go
@@ -0,0 +1,61 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os"
+ "testing"
+
+ "code.gitea.io/gitea/modules/generate"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetGeneralSigningSecret(t *testing.T) {
+ // when there is no general signing secret, it should be generated, and keep the same value
+ generalSigningSecret.Store(nil)
+ s1 := GetGeneralTokenSigningSecret()
+ assert.NotNil(t, s1)
+ s2 := GetGeneralTokenSigningSecret()
+ assert.Equal(t, s1, s2)
+
+ // the config value should always override any pre-generated value
+ cfg, _ := NewConfigProviderFromData(`
+[oauth2]
+JWT_SECRET = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
+`)
+ defer test.MockVariableValue(&InstallLock, true)()
+ loadOAuth2From(cfg)
+ actual := GetGeneralTokenSigningSecret()
+ expected, _ := generate.DecodeJwtSecret("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
+ assert.Len(t, actual, 32)
+ assert.EqualValues(t, expected, actual)
+}
+
+func TestGetGeneralSigningSecretSave(t *testing.T) {
+ defer test.MockVariableValue(&InstallLock, true)()
+
+ old := GetGeneralTokenSigningSecret()
+ assert.Len(t, old, 32)
+
+ tmpFile := t.TempDir() + "/app.ini"
+ _ = os.WriteFile(tmpFile, nil, 0o644)
+ cfg, _ := NewConfigProviderFromFile(tmpFile)
+ loadOAuth2From(cfg)
+ generated := GetGeneralTokenSigningSecret()
+ assert.Len(t, generated, 32)
+ assert.NotEqual(t, old, generated)
+
+ generalSigningSecret.Store(nil)
+ cfg, _ = NewConfigProviderFromFile(tmpFile)
+ loadOAuth2From(cfg)
+ again := GetGeneralTokenSigningSecret()
+ assert.Equal(t, generated, again)
+
+ iniContent, err := os.ReadFile(tmpFile)
+ require.NoError(t, err)
+ assert.Contains(t, string(iniContent), "JWT_SECRET = ")
+}
diff --git a/modules/setting/other.go b/modules/setting/other.go
new file mode 100644
index 0000000..4ba4947
--- /dev/null
+++ b/modules/setting/other.go
@@ -0,0 +1,29 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import "code.gitea.io/gitea/modules/log"
+
+type OtherConfig struct {
+ ShowFooterVersion bool
+ ShowFooterTemplateLoadTime bool
+ ShowFooterPoweredBy bool
+ EnableFeed bool
+ EnableSitemap bool
+}
+
+var Other = OtherConfig{
+ ShowFooterVersion: true,
+ ShowFooterTemplateLoadTime: true,
+ ShowFooterPoweredBy: true,
+ EnableSitemap: true,
+ EnableFeed: true,
+}
+
+func loadOtherFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("other")
+ if err := sec.MapTo(&Other); err != nil {
+ log.Fatal("Failed to map [other] settings: %v", err)
+ }
+}
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
new file mode 100644
index 0000000..b3f5061
--- /dev/null
+++ b/modules/setting/packages.go
@@ -0,0 +1,124 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "math"
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "github.com/dustin/go-humanize"
+)
+
+// Package registry settings
+var (
+ Packages = struct {
+ Storage *Storage
+ Enabled bool
+ ChunkedUploadPath string
+ RegistryHost string
+
+ LimitTotalOwnerCount int64
+ LimitTotalOwnerSize int64
+ LimitSizeAlpine int64
+ LimitSizeArch int64
+ LimitSizeCargo int64
+ LimitSizeChef int64
+ LimitSizeComposer int64
+ LimitSizeConan int64
+ LimitSizeConda int64
+ LimitSizeContainer int64
+ LimitSizeCran int64
+ LimitSizeDebian int64
+ LimitSizeGeneric int64
+ LimitSizeGo int64
+ LimitSizeHelm int64
+ LimitSizeMaven int64
+ LimitSizeNpm int64
+ LimitSizeNuGet int64
+ LimitSizePub int64
+ LimitSizePyPI int64
+ LimitSizeRpm int64
+ LimitSizeRubyGems int64
+ LimitSizeSwift int64
+ LimitSizeVagrant int64
+ DefaultRPMSignEnabled bool
+ }{
+ Enabled: true,
+ LimitTotalOwnerCount: -1,
+ }
+)
+
+func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
+ sec, _ := rootCfg.GetSection("packages")
+ if sec == nil {
+ Packages.Storage, err = getStorage(rootCfg, "packages", "", nil)
+ return err
+ }
+
+ if err = sec.MapTo(&Packages); err != nil {
+ return fmt.Errorf("failed to map Packages settings: %v", err)
+ }
+
+ Packages.Storage, err = getStorage(rootCfg, "packages", "", sec)
+ if err != nil {
+ return err
+ }
+
+ appURL, _ := url.Parse(AppURL)
+ Packages.RegistryHost = appURL.Host
+
+ Packages.ChunkedUploadPath = filepath.ToSlash(sec.Key("CHUNKED_UPLOAD_PATH").MustString("tmp/package-upload"))
+ if !filepath.IsAbs(Packages.ChunkedUploadPath) {
+ Packages.ChunkedUploadPath = filepath.ToSlash(filepath.Join(AppDataPath, Packages.ChunkedUploadPath))
+ }
+
+ if HasInstallLock(rootCfg) {
+ if err := os.MkdirAll(Packages.ChunkedUploadPath, os.ModePerm); err != nil {
+ return fmt.Errorf("unable to create chunked upload directory: %s (%v)", Packages.ChunkedUploadPath, err)
+ }
+ }
+
+ Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE")
+ Packages.LimitSizeAlpine = mustBytes(sec, "LIMIT_SIZE_ALPINE")
+ Packages.LimitSizeArch = mustBytes(sec, "LIMIT_SIZE_ARCH")
+ Packages.LimitSizeCargo = mustBytes(sec, "LIMIT_SIZE_CARGO")
+ Packages.LimitSizeChef = mustBytes(sec, "LIMIT_SIZE_CHEF")
+ Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER")
+ Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN")
+ Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA")
+ Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER")
+ Packages.LimitSizeCran = mustBytes(sec, "LIMIT_SIZE_CRAN")
+ Packages.LimitSizeDebian = mustBytes(sec, "LIMIT_SIZE_DEBIAN")
+ Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC")
+ Packages.LimitSizeGo = mustBytes(sec, "LIMIT_SIZE_GO")
+ Packages.LimitSizeHelm = mustBytes(sec, "LIMIT_SIZE_HELM")
+ Packages.LimitSizeMaven = mustBytes(sec, "LIMIT_SIZE_MAVEN")
+ Packages.LimitSizeNpm = mustBytes(sec, "LIMIT_SIZE_NPM")
+ Packages.LimitSizeNuGet = mustBytes(sec, "LIMIT_SIZE_NUGET")
+ Packages.LimitSizePub = mustBytes(sec, "LIMIT_SIZE_PUB")
+ Packages.LimitSizePyPI = mustBytes(sec, "LIMIT_SIZE_PYPI")
+ Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
+ Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
+ Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
+ Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
+ Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
+ return nil
+}
+
+func mustBytes(section ConfigSection, key string) int64 {
+ const noLimit = "-1"
+
+ value := section.Key(key).MustString(noLimit)
+ if value == noLimit {
+ return -1
+ }
+ bytes, err := humanize.ParseBytes(value)
+ if err != nil || bytes > math.MaxInt64 {
+ return -1
+ }
+ return int64(bytes)
+}
diff --git a/modules/setting/packages_test.go b/modules/setting/packages_test.go
new file mode 100644
index 0000000..78eb4b4
--- /dev/null
+++ b/modules/setting/packages_test.go
@@ -0,0 +1,199 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMustBytes(t *testing.T) {
+ test := func(value string) int64 {
+ cfg, err := NewConfigProviderFromData("[test]")
+ require.NoError(t, err)
+ sec := cfg.Section("test")
+ sec.NewKey("VALUE", value)
+
+ return mustBytes(sec, "VALUE")
+ }
+
+ assert.EqualValues(t, -1, test(""))
+ assert.EqualValues(t, -1, test("-1"))
+ assert.EqualValues(t, 0, test("0"))
+ assert.EqualValues(t, 1, test("1"))
+ assert.EqualValues(t, 10000, test("10000"))
+ assert.EqualValues(t, 1000000, test("1 mb"))
+ assert.EqualValues(t, 1048576, test("1mib"))
+ assert.EqualValues(t, 1782579, test("1.7mib"))
+ assert.EqualValues(t, -1, test("1 yib")) // too large
+}
+
+func Test_getStorageInheritNameSectionTypeForPackages(t *testing.T) {
+ // packages storage inherits from storage if nothing configured
+ iniStr := `
+[storage]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadPackagesFrom(cfg))
+
+ assert.EqualValues(t, "minio", Packages.Storage.Type)
+ assert.EqualValues(t, "packages/", Packages.Storage.MinioConfig.BasePath)
+
+ // we can also configure packages storage directly
+ iniStr = `
+[storage.packages]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadPackagesFrom(cfg))
+
+ assert.EqualValues(t, "minio", Packages.Storage.Type)
+ assert.EqualValues(t, "packages/", Packages.Storage.MinioConfig.BasePath)
+
+ // or we can indicate the storage type in the packages section
+ iniStr = `
+[packages]
+STORAGE_TYPE = my_minio
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadPackagesFrom(cfg))
+
+ assert.EqualValues(t, "minio", Packages.Storage.Type)
+ assert.EqualValues(t, "packages/", Packages.Storage.MinioConfig.BasePath)
+
+ // or we can indicate the storage type and minio base path in the packages section
+ iniStr = `
+[packages]
+STORAGE_TYPE = my_minio
+MINIO_BASE_PATH = my_packages/
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadPackagesFrom(cfg))
+
+ assert.EqualValues(t, "minio", Packages.Storage.Type)
+ assert.EqualValues(t, "my_packages/", Packages.Storage.MinioConfig.BasePath)
+}
+
+func Test_PackageStorage1(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[packages]
+MINIO_BASE_PATH = packages/
+SERVE_DIRECT = true
+[storage]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadPackagesFrom(cfg))
+ storage := Packages.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "packages/", storage.MinioConfig.BasePath)
+ assert.True(t, storage.MinioConfig.ServeDirect)
+}
+
+func Test_PackageStorage2(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[storage.packages]
+MINIO_BASE_PATH = packages/
+SERVE_DIRECT = true
+[storage]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadPackagesFrom(cfg))
+ storage := Packages.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "packages/", storage.MinioConfig.BasePath)
+ assert.True(t, storage.MinioConfig.ServeDirect)
+}
+
+func Test_PackageStorage3(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[packages]
+STORAGE_TYPE = my_cfg
+MINIO_BASE_PATH = my_packages/
+SERVE_DIRECT = true
+[storage.my_cfg]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadPackagesFrom(cfg))
+ storage := Packages.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "my_packages/", storage.MinioConfig.BasePath)
+ assert.True(t, storage.MinioConfig.ServeDirect)
+}
+
+func Test_PackageStorage4(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[storage.packages]
+STORAGE_TYPE = my_cfg
+MINIO_BASE_PATH = my_packages/
+SERVE_DIRECT = true
+[storage.my_cfg]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadPackagesFrom(cfg))
+ storage := Packages.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "my_packages/", storage.MinioConfig.BasePath)
+ assert.True(t, storage.MinioConfig.ServeDirect)
+}
diff --git a/modules/setting/path.go b/modules/setting/path.go
new file mode 100644
index 0000000..85d0e06
--- /dev/null
+++ b/modules/setting/path.go
@@ -0,0 +1,214 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var (
+ // AppPath represents the path to the gitea binary
+ AppPath string
+
+ // AppWorkPath is the "working directory" of Gitea. It maps to the: WORK_PATH in app.ini, "--work-path" flag, environment variable GITEA_WORK_DIR.
+ // If that is not set it is the default set here by the linker or failing that the directory of AppPath.
+ // It is used as the base path for several other paths.
+ AppWorkPath string
+ CustomPath string // Custom directory path. Env: GITEA_CUSTOM
+ CustomConf string
+
+ appWorkPathBuiltin string
+ customPathBuiltin string
+ customConfBuiltin string
+
+ AppWorkPathMismatch bool
+)
+
+func getAppPath() (string, error) {
+ var appPath string
+ var err error
+ if IsWindows && filepath.IsAbs(os.Args[0]) {
+ appPath = filepath.Clean(os.Args[0])
+ } else {
+ appPath, err = exec.LookPath(os.Args[0])
+ }
+ if err != nil {
+ if !errors.Is(err, exec.ErrDot) {
+ return "", err
+ }
+ appPath, err = filepath.Abs(os.Args[0])
+ }
+ if err != nil {
+ return "", err
+ }
+ appPath, err = filepath.Abs(appPath)
+ if err != nil {
+ return "", err
+ }
+ // Note: (legacy code) we don't use path.Dir here because it does not handle case which path starts with two "/" in Windows: "//psf/Home/..."
+ return strings.ReplaceAll(appPath, "\\", "/"), err
+}
+
+func init() {
+ var err error
+ if AppPath, err = getAppPath(); err != nil {
+ log.Fatal("Failed to get app path: %v", err)
+ }
+
+ if AppWorkPath == "" {
+ AppWorkPath = filepath.Dir(AppPath)
+ }
+
+ appWorkPathBuiltin = AppWorkPath
+ customPathBuiltin = CustomPath
+ customConfBuiltin = CustomConf
+}
+
+type ArgWorkPathAndCustomConf struct {
+ WorkPath string
+ CustomPath string
+ CustomConf string
+}
+
+type stringWithDefault struct {
+ Value string
+ IsSet bool
+}
+
+func (s *stringWithDefault) Set(v string) {
+ s.Value = v
+ s.IsSet = true
+}
+
+// InitWorkPathAndCommonConfig will set AppWorkPath, CustomPath and CustomConf, init default config provider by CustomConf and load common settings,
+func InitWorkPathAndCommonConfig(getEnvFn func(name string) string, args ArgWorkPathAndCustomConf) {
+ InitWorkPathAndCfgProvider(getEnvFn, args)
+ LoadCommonSettings()
+}
+
+// InitWorkPathAndCfgProvider will set AppWorkPath, CustomPath and CustomConf, init default config provider by CustomConf
+func InitWorkPathAndCfgProvider(getEnvFn func(name string) string, args ArgWorkPathAndCustomConf) {
+ tryAbsPath := func(paths ...string) string {
+ s := paths[len(paths)-1]
+ for i := len(paths) - 2; i >= 0; i-- {
+ if filepath.IsAbs(s) {
+ break
+ }
+ s = filepath.Join(paths[i], s)
+ }
+ return s
+ }
+
+ var err error
+ tmpWorkPath := stringWithDefault{Value: appWorkPathBuiltin}
+ if tmpWorkPath.Value == "" {
+ tmpWorkPath.Value = filepath.Dir(AppPath)
+ }
+ tmpCustomPath := stringWithDefault{Value: customPathBuiltin}
+ if tmpCustomPath.Value == "" {
+ tmpCustomPath.Value = "custom"
+ }
+ tmpCustomConf := stringWithDefault{Value: customConfBuiltin}
+ if tmpCustomConf.Value == "" {
+ tmpCustomConf.Value = "conf/app.ini"
+ }
+
+ readFromEnv := func() {
+ envWorkPath := getEnvFn("GITEA_WORK_DIR")
+ if envWorkPath != "" {
+ tmpWorkPath.Set(envWorkPath)
+ if !filepath.IsAbs(tmpWorkPath.Value) {
+ log.Fatal("GITEA_WORK_DIR (work path) must be absolute path")
+ }
+ }
+
+ envWorkPath = getEnvFn("FORGEJO_WORK_DIR")
+ if envWorkPath != "" {
+ tmpWorkPath.Set(envWorkPath)
+ if !filepath.IsAbs(tmpWorkPath.Value) {
+ log.Fatal("FORGEJO_WORK_DIR (work path) must be absolute path")
+ }
+ }
+
+ envCustomPath := getEnvFn("GITEA_CUSTOM")
+ if envCustomPath != "" {
+ tmpCustomPath.Set(envCustomPath)
+ if !filepath.IsAbs(tmpCustomPath.Value) {
+ log.Fatal("GITEA_CUSTOM (custom path) must be absolute path")
+ }
+ }
+
+ envCustomPath = getEnvFn("FORGEJO_CUSTOM")
+ if envCustomPath != "" {
+ tmpCustomPath.Set(envCustomPath)
+ if !filepath.IsAbs(tmpCustomPath.Value) {
+ log.Fatal("FORGEJO_CUSTOM (custom path) must be absolute path")
+ }
+ }
+ }
+
+ readFromArgs := func() {
+ if args.WorkPath != "" {
+ tmpWorkPath.Set(args.WorkPath)
+ if !filepath.IsAbs(tmpWorkPath.Value) {
+ log.Fatal("--work-path must be absolute path")
+ }
+ }
+ if args.CustomPath != "" {
+ tmpCustomPath.Set(args.CustomPath) // if it is not abs, it will be based on work-path, it shouldn't happen
+ if !filepath.IsAbs(tmpCustomPath.Value) {
+ log.Error("--custom-path must be absolute path")
+ }
+ }
+ if args.CustomConf != "" {
+ tmpCustomConf.Set(args.CustomConf)
+ if !filepath.IsAbs(tmpCustomConf.Value) {
+ // the config path can be relative to the real current working path
+ if tmpCustomConf.Value, err = filepath.Abs(tmpCustomConf.Value); err != nil {
+ log.Fatal("Failed to get absolute path of config %q: %v", tmpCustomConf.Value, err)
+ }
+ }
+ }
+ }
+
+ readFromEnv()
+ readFromArgs()
+
+ if !tmpCustomConf.IsSet {
+ tmpCustomConf.Set(tryAbsPath(tmpWorkPath.Value, tmpCustomPath.Value, tmpCustomConf.Value))
+ }
+
+ // only read the config but do not load/init anything more, because the AppWorkPath and CustomPath are not ready
+ InitCfgProvider(tmpCustomConf.Value)
+ if HasInstallLock(CfgProvider) {
+ ClearEnvConfigKeys() // if the instance has been installed, do not pass the environment variables to sub-processes
+ }
+ configWorkPath := ConfigSectionKeyString(CfgProvider.Section(""), "WORK_PATH")
+ if configWorkPath != "" {
+ if !filepath.IsAbs(configWorkPath) {
+ log.Fatal("WORK_PATH in %q must be absolute path", configWorkPath)
+ }
+ configWorkPath = filepath.Clean(configWorkPath)
+ if tmpWorkPath.Value != "" && (getEnvFn("GITEA_WORK_DIR") != "" || getEnvFn("FORGEJO_WORK_DIR") != "" || args.WorkPath != "") {
+ fi1, err1 := os.Stat(tmpWorkPath.Value)
+ fi2, err2 := os.Stat(configWorkPath)
+ if err1 != nil || err2 != nil || !os.SameFile(fi1, fi2) {
+ AppWorkPathMismatch = true
+ }
+ }
+ tmpWorkPath.Set(configWorkPath)
+ }
+
+ tmpCustomPath.Set(tryAbsPath(tmpWorkPath.Value, tmpCustomPath.Value))
+
+ AppWorkPath = tmpWorkPath.Value
+ CustomPath = tmpCustomPath.Value
+ CustomConf = tmpCustomConf.Value
+}
diff --git a/modules/setting/path_test.go b/modules/setting/path_test.go
new file mode 100644
index 0000000..4508bae
--- /dev/null
+++ b/modules/setting/path_test.go
@@ -0,0 +1,243 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type envVars map[string]string
+
+func (e envVars) Getenv(key string) string {
+ return e[key]
+}
+
+func TestInitWorkPathAndCommonConfig(t *testing.T) {
+ testInit := func(defaultWorkPath, defaultCustomPath, defaultCustomConf string) {
+ AppWorkPathMismatch = false
+ AppWorkPath = defaultWorkPath
+ appWorkPathBuiltin = defaultWorkPath
+ CustomPath = defaultCustomPath
+ customPathBuiltin = defaultCustomPath
+ CustomConf = defaultCustomConf
+ customConfBuiltin = defaultCustomConf
+ }
+
+ fp := filepath.Join
+
+ tmpDir := t.TempDir()
+ dirFoo := fp(tmpDir, "foo")
+ dirBar := fp(tmpDir, "bar")
+ dirXxx := fp(tmpDir, "xxx")
+ dirYyy := fp(tmpDir, "yyy")
+
+ t.Run("Default", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirFoo, "custom"), CustomPath)
+ assert.Equal(t, fp(dirFoo, "custom/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("WorkDir(env)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirBar, AppWorkPath)
+ assert.Equal(t, fp(dirBar, "custom"), CustomPath)
+ assert.Equal(t, fp(dirBar, "custom/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("WorkDir(env,arg)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{WorkPath: dirXxx})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, fp(dirXxx, "custom/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("WorkDir(env)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirBar, AppWorkPath)
+ assert.Equal(t, fp(dirBar, "custom"), CustomPath)
+ assert.Equal(t, fp(dirBar, "custom/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("WorkDir(env,arg)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{WorkPath: dirXxx})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, fp(dirXxx, "custom/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("CustomPath(env)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_CUSTOM": fp(dirBar, "custom1")}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirBar, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirBar, "custom1/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("CustomPath(env,arg)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_CUSTOM": fp(dirBar, "custom1")}.Getenv, ArgWorkPathAndCustomConf{CustomPath: "custom2"})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirFoo, "custom2"), CustomPath)
+ assert.Equal(t, fp(dirFoo, "custom2/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("CustomPath(env)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_CUSTOM": fp(dirBar, "custom1")}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirBar, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirBar, "custom1/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("CustomPath(env,arg)", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_CUSTOM": fp(dirBar, "custom1")}.Getenv, ArgWorkPathAndCustomConf{CustomPath: "custom2"})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirFoo, "custom2"), CustomPath)
+ assert.Equal(t, fp(dirFoo, "custom2/conf/app.ini"), CustomConf)
+ })
+
+ t.Run("CustomConf", func(t *testing.T) {
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: "app1.ini"})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ cwd, _ := os.Getwd()
+ assert.Equal(t, fp(cwd, "app1.ini"), CustomConf)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: fp(dirBar, "app1.ini")})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirBar, "app1.ini"), CustomConf)
+ })
+
+ t.Run("CustomConfOverrideWorkPath", func(t *testing.T) {
+ iniWorkPath := fp(tmpDir, "app-workpath.ini")
+ _ = os.WriteFile(iniWorkPath, []byte("WORK_PATH="+dirXxx), 0o644)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.False(t, AppWorkPathMismatch)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.True(t, AppWorkPathMismatch)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{WorkPath: dirBar, CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.True(t, AppWorkPathMismatch)
+ })
+
+ t.Run("CustomConfOverrideWorkPath", func(t *testing.T) {
+ iniWorkPath := fp(tmpDir, "app-workpath.ini")
+ _ = os.WriteFile(iniWorkPath, []byte("WORK_PATH="+dirXxx), 0o644)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.False(t, AppWorkPathMismatch)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_WORK_DIR": dirBar}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.True(t, AppWorkPathMismatch)
+
+ testInit(dirFoo, "", "")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{WorkPath: dirBar, CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ assert.True(t, AppWorkPathMismatch)
+ })
+
+ t.Run("Builtin", func(t *testing.T) {
+ testInit(dirFoo, dirBar, dirXxx)
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, dirBar, CustomPath)
+ assert.Equal(t, dirXxx, CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirFoo, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirFoo, "custom1/cfg.ini"), CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_WORK_DIR": dirYyy}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirYyy, AppWorkPath)
+ assert.Equal(t, fp(dirYyy, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirYyy, "custom1/cfg.ini"), CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{"GITEA_CUSTOM": dirYyy}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, dirYyy, CustomPath)
+ assert.Equal(t, fp(dirYyy, "cfg.ini"), CustomConf)
+
+ iniWorkPath := fp(tmpDir, "app-workpath.ini")
+ _ = os.WriteFile(iniWorkPath, []byte("WORK_PATH="+dirXxx), 0o644)
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom1"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ })
+
+ t.Run("Builtin", func(t *testing.T) {
+ testInit(dirFoo, dirBar, dirXxx)
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, dirBar, CustomPath)
+ assert.Equal(t, dirXxx, CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, fp(dirFoo, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirFoo, "custom1/cfg.ini"), CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_WORK_DIR": dirYyy}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirYyy, AppWorkPath)
+ assert.Equal(t, fp(dirYyy, "custom1"), CustomPath)
+ assert.Equal(t, fp(dirYyy, "custom1/cfg.ini"), CustomConf)
+
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{"FORGEJO_CUSTOM": dirYyy}.Getenv, ArgWorkPathAndCustomConf{})
+ assert.Equal(t, dirFoo, AppWorkPath)
+ assert.Equal(t, dirYyy, CustomPath)
+ assert.Equal(t, fp(dirYyy, "cfg.ini"), CustomConf)
+
+ iniWorkPath := fp(tmpDir, "app-workpath.ini")
+ _ = os.WriteFile(iniWorkPath, []byte("WORK_PATH="+dirXxx), 0o644)
+ testInit(dirFoo, "custom1", "cfg.ini")
+ InitWorkPathAndCommonConfig(envVars{}.Getenv, ArgWorkPathAndCustomConf{CustomConf: iniWorkPath})
+ assert.Equal(t, dirXxx, AppWorkPath)
+ assert.Equal(t, fp(dirXxx, "custom1"), CustomPath)
+ assert.Equal(t, iniWorkPath, CustomConf)
+ })
+}
diff --git a/modules/setting/picture.go b/modules/setting/picture.go
new file mode 100644
index 0000000..fafae45
--- /dev/null
+++ b/modules/setting/picture.go
@@ -0,0 +1,109 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Avatar settings
+
+var (
+ Avatar = struct {
+ Storage *Storage
+
+ MaxWidth int
+ MaxHeight int
+ MaxFileSize int64
+ MaxOriginSize int64
+ RenderedSizeFactor int
+ }{
+ MaxWidth: 4096,
+ MaxHeight: 4096,
+ MaxFileSize: 1048576,
+ MaxOriginSize: 262144,
+ RenderedSizeFactor: 2,
+ }
+
+ GravatarSource string
+ DisableGravatar bool // Depreciated: migrated to database
+ EnableFederatedAvatar bool // Depreciated: migrated to database
+
+ RepoAvatar = struct {
+ Storage *Storage
+
+ Fallback string
+ FallbackImage string
+ }{}
+)
+
+func loadAvatarsFrom(rootCfg ConfigProvider) error {
+ sec := rootCfg.Section("picture")
+
+ avatarSec := rootCfg.Section("avatar")
+ storageType := sec.Key("AVATAR_STORAGE_TYPE").MustString("")
+ // Specifically default PATH to AVATAR_UPLOAD_PATH
+ avatarSec.Key("PATH").MustString(sec.Key("AVATAR_UPLOAD_PATH").String())
+
+ var err error
+ Avatar.Storage, err = getStorage(rootCfg, "avatars", storageType, avatarSec)
+ if err != nil {
+ return err
+ }
+
+ Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096)
+ Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(4096)
+ Avatar.MaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
+ Avatar.MaxOriginSize = sec.Key("AVATAR_MAX_ORIGIN_SIZE").MustInt64(262144)
+ Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2)
+
+ switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
+ case "duoshuo":
+ GravatarSource = "http://gravatar.duoshuo.com/avatar/"
+ case "gravatar":
+ GravatarSource = "https://secure.gravatar.com/avatar/"
+ case "libravatar":
+ GravatarSource = "https://seccdn.libravatar.org/avatar/"
+ default:
+ GravatarSource = source
+ }
+
+ DisableGravatar = sec.Key("DISABLE_GRAVATAR").MustBool(GetDefaultDisableGravatar())
+ deprecatedSettingDB(rootCfg, "", "DISABLE_GRAVATAR")
+ EnableFederatedAvatar = sec.Key("ENABLE_FEDERATED_AVATAR").MustBool(GetDefaultEnableFederatedAvatar(DisableGravatar))
+ deprecatedSettingDB(rootCfg, "", "ENABLE_FEDERATED_AVATAR")
+
+ return nil
+}
+
+func GetDefaultDisableGravatar() bool {
+ return OfflineMode
+}
+
+func GetDefaultEnableFederatedAvatar(disableGravatar bool) bool {
+ v := !InstallLock
+ if OfflineMode {
+ v = false
+ }
+ if disableGravatar {
+ v = false
+ }
+ return v
+}
+
+func loadRepoAvatarFrom(rootCfg ConfigProvider) error {
+ sec := rootCfg.Section("picture")
+
+ repoAvatarSec := rootCfg.Section("repo-avatar")
+ storageType := sec.Key("REPOSITORY_AVATAR_STORAGE_TYPE").MustString("")
+ // Specifically default PATH to AVATAR_UPLOAD_PATH
+ repoAvatarSec.Key("PATH").MustString(sec.Key("REPOSITORY_AVATAR_UPLOAD_PATH").String())
+
+ var err error
+ RepoAvatar.Storage, err = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec)
+ if err != nil {
+ return err
+ }
+
+ RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none")
+ RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString(AppSubURL + "/assets/img/repo_default.png")
+
+ return nil
+}
diff --git a/modules/setting/project.go b/modules/setting/project.go
new file mode 100644
index 0000000..803e933
--- /dev/null
+++ b/modules/setting/project.go
@@ -0,0 +1,19 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Project settings
+var (
+ Project = struct {
+ ProjectBoardBasicKanbanType []string
+ ProjectBoardBugTriageType []string
+ }{
+ ProjectBoardBasicKanbanType: []string{"To Do", "In Progress", "Done"},
+ ProjectBoardBugTriageType: []string{"Needs Triage", "High Priority", "Low Priority", "Closed"},
+ }
+)
+
+func loadProjectFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "project", &Project)
+}
diff --git a/modules/setting/proxy.go b/modules/setting/proxy.go
new file mode 100644
index 0000000..4ff420d
--- /dev/null
+++ b/modules/setting/proxy.go
@@ -0,0 +1,37 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/url"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Proxy settings
+var Proxy = struct {
+ Enabled bool
+ ProxyURL string
+ ProxyURLFixed *url.URL
+ ProxyHosts []string
+}{
+ Enabled: false,
+ ProxyURL: "",
+ ProxyHosts: []string{},
+}
+
+func loadProxyFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("proxy")
+ Proxy.Enabled = sec.Key("PROXY_ENABLED").MustBool(false)
+ Proxy.ProxyURL = sec.Key("PROXY_URL").MustString("")
+ if Proxy.ProxyURL != "" {
+ var err error
+ Proxy.ProxyURLFixed, err = url.Parse(Proxy.ProxyURL)
+ if err != nil {
+ log.Error("Global PROXY_URL is not valid")
+ Proxy.ProxyURL = ""
+ }
+ }
+ Proxy.ProxyHosts = sec.Key("PROXY_HOSTS").Strings(",")
+}
diff --git a/modules/setting/queue.go b/modules/setting/queue.go
new file mode 100644
index 0000000..251a6c1
--- /dev/null
+++ b/modules/setting/queue.go
@@ -0,0 +1,120 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+ "runtime"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// QueueSettings represent the settings for a queue from the ini
+type QueueSettings struct {
+ Name string // not an INI option, it is the name for [queue.the-name] section
+
+ Type string
+ Datadir string
+ ConnStr string // for leveldb or redis
+ Length int // max queue length before blocking
+
+ QueueName, SetName string // the name suffix for storage (db key, redis key), "set" is for unique queue
+
+ BatchLength int
+ MaxWorkers int
+}
+
+func GetQueueSettings(rootCfg ConfigProvider, name string) (QueueSettings, error) {
+ queueSettingsDefault := QueueSettings{
+ Type: "level", // dummy, channel, level, redis
+ Datadir: "queues/common", // relative to AppDataPath
+ Length: 100000, // queue length before a channel queue will block
+
+ QueueName: "_queue",
+ SetName: "_unique",
+ BatchLength: 20,
+ MaxWorkers: runtime.NumCPU() / 2,
+ }
+ if queueSettingsDefault.MaxWorkers < 1 {
+ queueSettingsDefault.MaxWorkers = 1
+ }
+ if queueSettingsDefault.MaxWorkers > 10 {
+ queueSettingsDefault.MaxWorkers = 10
+ }
+
+ // deep copy default settings
+ cfg := QueueSettings{}
+ if cfgBs, err := json.Marshal(queueSettingsDefault); err != nil {
+ return cfg, err
+ } else if err = json.Unmarshal(cfgBs, &cfg); err != nil {
+ return cfg, err
+ }
+
+ cfg.Name = name
+ if sec, err := rootCfg.GetSection("queue"); err == nil {
+ if err = sec.MapTo(&cfg); err != nil {
+ log.Error("Failed to map queue common config for %q: %v", name, err)
+ return cfg, nil
+ }
+ }
+ if sec, err := rootCfg.GetSection("queue." + name); err == nil {
+ if err = sec.MapTo(&cfg); err != nil {
+ log.Error("Failed to map queue spec config for %q: %v", name, err)
+ return cfg, nil
+ }
+ if sec.HasKey("CONN_STR") {
+ cfg.ConnStr = sec.Key("CONN_STR").String()
+ }
+ }
+
+ if cfg.Datadir == "" {
+ cfg.Datadir = queueSettingsDefault.Datadir
+ }
+ if !filepath.IsAbs(cfg.Datadir) {
+ cfg.Datadir = filepath.Join(AppDataPath, cfg.Datadir)
+ }
+ cfg.Datadir = filepath.ToSlash(cfg.Datadir)
+
+ if cfg.Type == "redis" && cfg.ConnStr == "" {
+ cfg.ConnStr = "redis://127.0.0.1:6379/0"
+ }
+
+ if cfg.Length <= 0 {
+ cfg.Length = queueSettingsDefault.Length
+ }
+ if cfg.MaxWorkers <= 0 {
+ cfg.MaxWorkers = queueSettingsDefault.MaxWorkers
+ }
+ if cfg.BatchLength <= 0 {
+ cfg.BatchLength = queueSettingsDefault.BatchLength
+ }
+
+ return cfg, nil
+}
+
+func LoadQueueSettings() {
+ loadQueueFrom(CfgProvider)
+}
+
+func loadQueueFrom(rootCfg ConfigProvider) {
+ hasOld := false
+ handleOldLengthConfiguration := func(rootCfg ConfigProvider, newQueueName, oldSection, oldKey string) {
+ if rootCfg.Section(oldSection).HasKey(oldKey) {
+ hasOld = true
+ log.Error("Removed queue option: `[%s].%s`. Use new options in `[queue.%s]`", oldSection, oldKey, newQueueName)
+ }
+ }
+ handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_TYPE")
+ handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_BATCH_NUMBER")
+ handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_DIR")
+ handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_CONN_STR")
+ handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "UPDATE_BUFFER_LEN")
+ handleOldLengthConfiguration(rootCfg, "mailer", "mailer", "SEND_BUFFER_LEN")
+ handleOldLengthConfiguration(rootCfg, "pr_patch_checker", "repository", "PULL_REQUEST_QUEUE_LENGTH")
+ handleOldLengthConfiguration(rootCfg, "mirror", "repository", "MIRROR_QUEUE_LENGTH")
+ if hasOld {
+ log.Fatal("Please update your app.ini to remove deprecated config options")
+ }
+}
diff --git a/modules/setting/quota.go b/modules/setting/quota.go
new file mode 100644
index 0000000..05e14ba
--- /dev/null
+++ b/modules/setting/quota.go
@@ -0,0 +1,26 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Quota settings
+var Quota = struct {
+ Enabled bool `ini:"ENABLED"`
+ DefaultGroups []string `ini:"DEFAULT_GROUPS"`
+
+ Default struct {
+ Total int64
+ } `ini:"quota.default"`
+}{
+ Enabled: false,
+ DefaultGroups: []string{},
+ Default: struct {
+ Total int64
+ }{
+ Total: -1,
+ },
+}
+
+func loadQuotaFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "quota", &Quota)
+}
diff --git a/modules/setting/repository.go b/modules/setting/repository.go
new file mode 100644
index 0000000..6086dd1
--- /dev/null
+++ b/modules/setting/repository.go
@@ -0,0 +1,376 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// enumerates all the policy repository creating
+const (
+ RepoCreatingLastUserVisibility = "last"
+ RepoCreatingPrivate = "private"
+ RepoCreatingPublic = "public"
+)
+
+// MaxUserCardsPerPage sets maximum amount of watchers and stargazers shown per page
+// those pages use 2 or 3 column layout, so the value should be divisible by 2 and 3
+var MaxUserCardsPerPage = 36
+
+// MaxForksPerPage sets maximum amount of forks shown per page
+var MaxForksPerPage = 40
+
+// Repository settings
+var (
+ Repository = struct {
+ DetectedCharsetsOrder []string
+ DetectedCharsetScore map[string]int `ini:"-"`
+ AnsiCharset string
+ ForcePrivate bool
+ DefaultPrivate string
+ DefaultPushCreatePrivate bool
+ MaxCreationLimit int
+ PreferredLicenses []string
+ DisableHTTPGit bool
+ AccessControlAllowOrigin string
+ UseCompatSSHURI bool
+ GoGetCloneURLProtocol string
+ DefaultCloseIssuesViaCommitsInAnyBranch bool
+ EnablePushCreateUser bool
+ EnablePushCreateOrg bool
+ DisabledRepoUnits []string
+ DefaultRepoUnits []string
+ DefaultForkRepoUnits []string
+ PrefixArchiveFiles bool
+ DisableMigrations bool
+ DisableStars bool
+ DisableForks bool
+ DefaultBranch string
+ AllowAdoptionOfUnadoptedRepositories bool
+ AllowDeleteOfUnadoptedRepositories bool
+ DisableDownloadSourceArchives bool
+ AllowForkWithoutMaximumLimit bool
+
+ // Repository editor settings
+ Editor struct {
+ LineWrapExtensions []string
+ } `ini:"-"`
+
+ // Repository upload settings
+ Upload struct {
+ Enabled bool
+ TempPath string
+ AllowedTypes string
+ FileMaxSize int64
+ MaxFiles int
+ } `ini:"-"`
+
+ // Repository local settings
+ Local struct {
+ LocalCopyPath string
+ } `ini:"-"`
+
+ // Pull request settings
+ PullRequest struct {
+ WorkInProgressPrefixes []string
+ CloseKeywords []string
+ ReopenKeywords []string
+ DefaultMergeStyle string
+ DefaultMergeMessageCommitsLimit int
+ DefaultMergeMessageSize int
+ DefaultMergeMessageAllAuthors bool
+ DefaultMergeMessageMaxApprovers int
+ DefaultMergeMessageOfficialApproversOnly bool
+ PopulateSquashCommentWithCommitMessages bool
+ AddCoCommitterTrailers bool
+ TestConflictingPatchesWithGitApply bool
+ RetargetChildrenOnMerge bool
+ } `ini:"repository.pull-request"`
+
+ // Issue Setting
+ Issue struct {
+ LockReasons []string
+ MaxPinned int
+ } `ini:"repository.issue"`
+
+ Release struct {
+ AllowedTypes string
+ DefaultPagingNum int
+ } `ini:"repository.release"`
+
+ Signing struct {
+ SigningKey string
+ SigningName string
+ SigningEmail string
+ InitialCommit []string
+ CRUDActions []string `ini:"CRUD_ACTIONS"`
+ Merges []string
+ Wiki []string
+ DefaultTrustModel string
+ } `ini:"repository.signing"`
+
+ SettableFlags []string
+ EnableFlags bool
+ }{
+ DetectedCharsetsOrder: []string{
+ "UTF-8",
+ "UTF-16BE",
+ "UTF-16LE",
+ "UTF-32BE",
+ "UTF-32LE",
+ "ISO-8859-1",
+ "windows-1252",
+ "ISO-8859-2",
+ "windows-1250",
+ "ISO-8859-5",
+ "ISO-8859-6",
+ "ISO-8859-7",
+ "windows-1253",
+ "ISO-8859-8-I",
+ "windows-1255",
+ "ISO-8859-8",
+ "windows-1251",
+ "windows-1256",
+ "KOI8-R",
+ "ISO-8859-9",
+ "windows-1254",
+ "Shift_JIS",
+ "GB18030",
+ "EUC-JP",
+ "EUC-KR",
+ "Big5",
+ "ISO-2022-JP",
+ "ISO-2022-KR",
+ "ISO-2022-CN",
+ "IBM424_rtl",
+ "IBM424_ltr",
+ "IBM420_rtl",
+ "IBM420_ltr",
+ },
+ DetectedCharsetScore: map[string]int{},
+ AnsiCharset: "",
+ ForcePrivate: false,
+ DefaultPrivate: RepoCreatingLastUserVisibility,
+ DefaultPushCreatePrivate: true,
+ MaxCreationLimit: -1,
+ PreferredLicenses: []string{"Apache-2.0", "MIT"},
+ DisableHTTPGit: false,
+ AccessControlAllowOrigin: "",
+ UseCompatSSHURI: true,
+ DefaultCloseIssuesViaCommitsInAnyBranch: false,
+ EnablePushCreateUser: false,
+ EnablePushCreateOrg: false,
+ DisabledRepoUnits: []string{},
+ DefaultRepoUnits: []string{},
+ DefaultForkRepoUnits: []string{},
+ PrefixArchiveFiles: true,
+ DisableMigrations: false,
+ DisableStars: false,
+ DisableForks: false,
+ DefaultBranch: "main",
+ AllowForkWithoutMaximumLimit: true,
+
+ // Repository editor settings
+ Editor: struct {
+ LineWrapExtensions []string
+ }{
+ LineWrapExtensions: strings.Split(".txt,.md,.markdown,.mdown,.mkd,.livemd,", ","),
+ },
+
+ // Repository upload settings
+ Upload: struct {
+ Enabled bool
+ TempPath string
+ AllowedTypes string
+ FileMaxSize int64
+ MaxFiles int
+ }{
+ Enabled: true,
+ TempPath: "data/tmp/uploads",
+ AllowedTypes: "",
+ FileMaxSize: 50,
+ MaxFiles: 5,
+ },
+
+ // Repository local settings
+ Local: struct {
+ LocalCopyPath string
+ }{
+ LocalCopyPath: "tmp/local-repo",
+ },
+
+ // Pull request settings
+ PullRequest: struct {
+ WorkInProgressPrefixes []string
+ CloseKeywords []string
+ ReopenKeywords []string
+ DefaultMergeStyle string
+ DefaultMergeMessageCommitsLimit int
+ DefaultMergeMessageSize int
+ DefaultMergeMessageAllAuthors bool
+ DefaultMergeMessageMaxApprovers int
+ DefaultMergeMessageOfficialApproversOnly bool
+ PopulateSquashCommentWithCommitMessages bool
+ AddCoCommitterTrailers bool
+ TestConflictingPatchesWithGitApply bool
+ RetargetChildrenOnMerge bool
+ }{
+ WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
+ // Same as GitHub. See
+ // https://help.github.com/articles/closing-issues-via-commit-messages
+ CloseKeywords: strings.Split("close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved", ","),
+ ReopenKeywords: strings.Split("reopen,reopens,reopened", ","),
+ DefaultMergeStyle: "merge",
+ DefaultMergeMessageCommitsLimit: 50,
+ DefaultMergeMessageSize: 5 * 1024,
+ DefaultMergeMessageAllAuthors: false,
+ DefaultMergeMessageMaxApprovers: 10,
+ DefaultMergeMessageOfficialApproversOnly: true,
+ PopulateSquashCommentWithCommitMessages: false,
+ AddCoCommitterTrailers: true,
+ RetargetChildrenOnMerge: true,
+ },
+
+ // Issue settings
+ Issue: struct {
+ LockReasons []string
+ MaxPinned int
+ }{
+ LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
+ MaxPinned: 3,
+ },
+
+ Release: struct {
+ AllowedTypes string
+ DefaultPagingNum int
+ }{
+ AllowedTypes: "",
+ DefaultPagingNum: 10,
+ },
+
+ // Signing settings
+ Signing: struct {
+ SigningKey string
+ SigningName string
+ SigningEmail string
+ InitialCommit []string
+ CRUDActions []string `ini:"CRUD_ACTIONS"`
+ Merges []string
+ Wiki []string
+ DefaultTrustModel string
+ }{
+ SigningKey: "default",
+ SigningName: "",
+ SigningEmail: "",
+ InitialCommit: []string{"always"},
+ CRUDActions: []string{"pubkey", "twofa", "parentsigned"},
+ Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"},
+ Wiki: []string{"never"},
+ DefaultTrustModel: "collaborator",
+ },
+
+ EnableFlags: false,
+ }
+ RepoRootPath string
+ ScriptType = "bash"
+)
+
+func loadRepositoryFrom(rootCfg ConfigProvider) {
+ var err error
+ // Determine and create root git repository path.
+ sec := rootCfg.Section("repository")
+ Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
+ Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
+ Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https")
+ Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1)
+ Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch)
+ RepoRootPath = sec.Key("ROOT").MustString(path.Join(AppDataPath, "forgejo-repositories"))
+ if !filepath.IsAbs(RepoRootPath) {
+ RepoRootPath = filepath.Join(AppWorkPath, RepoRootPath)
+ } else {
+ RepoRootPath = filepath.Clean(RepoRootPath)
+ }
+ defaultDetectedCharsetsOrder := make([]string, 0, len(Repository.DetectedCharsetsOrder))
+ for _, charset := range Repository.DetectedCharsetsOrder {
+ defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder, strings.ToLower(strings.TrimSpace(charset)))
+ }
+ ScriptType = sec.Key("SCRIPT_TYPE").MustString("bash")
+
+ if _, err := exec.LookPath(ScriptType); err != nil {
+ log.Warn("SCRIPT_TYPE %q is not on the current PATH. Are you sure that this is the correct SCRIPT_TYPE?", ScriptType)
+ }
+
+ if err = sec.MapTo(&Repository); err != nil {
+ log.Fatal("Failed to map Repository settings: %v", err)
+ } else if err = rootCfg.Section("repository.editor").MapTo(&Repository.Editor); err != nil {
+ log.Fatal("Failed to map Repository.Editor settings: %v", err)
+ } else if err = rootCfg.Section("repository.upload").MapTo(&Repository.Upload); err != nil {
+ log.Fatal("Failed to map Repository.Upload settings: %v", err)
+ } else if err = rootCfg.Section("repository.local").MapTo(&Repository.Local); err != nil {
+ log.Fatal("Failed to map Repository.Local settings: %v", err)
+ } else if err = rootCfg.Section("repository.pull-request").MapTo(&Repository.PullRequest); err != nil {
+ log.Fatal("Failed to map Repository.PullRequest settings: %v", err)
+ }
+
+ if !rootCfg.Section("packages").Key("ENABLED").MustBool(Packages.Enabled) {
+ Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.packages")
+ }
+
+ if !rootCfg.Section("actions").Key("ENABLED").MustBool(Actions.Enabled) {
+ Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.actions")
+ }
+
+ // Handle default trustmodel settings
+ Repository.Signing.DefaultTrustModel = strings.ToLower(strings.TrimSpace(Repository.Signing.DefaultTrustModel))
+ if Repository.Signing.DefaultTrustModel == "default" {
+ Repository.Signing.DefaultTrustModel = "collaborator"
+ }
+
+ // Handle preferred charset orders
+ preferred := make([]string, 0, len(Repository.DetectedCharsetsOrder))
+ for _, charset := range Repository.DetectedCharsetsOrder {
+ canonicalCharset := strings.ToLower(strings.TrimSpace(charset))
+ preferred = append(preferred, canonicalCharset)
+ // remove it from the defaults
+ for i, charset := range defaultDetectedCharsetsOrder {
+ if charset == canonicalCharset {
+ defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder[:i], defaultDetectedCharsetsOrder[i+1:]...)
+ break
+ }
+ }
+ }
+
+ i := 0
+ for _, charset := range preferred {
+ // Add the defaults
+ if charset == "defaults" {
+ for _, charset := range defaultDetectedCharsetsOrder {
+ canonicalCharset := strings.ToLower(strings.TrimSpace(charset))
+ if _, has := Repository.DetectedCharsetScore[canonicalCharset]; !has {
+ Repository.DetectedCharsetScore[canonicalCharset] = i
+ i++
+ }
+ }
+ continue
+ }
+ if _, has := Repository.DetectedCharsetScore[charset]; !has {
+ Repository.DetectedCharsetScore[charset] = i
+ i++
+ }
+ }
+
+ if !filepath.IsAbs(Repository.Upload.TempPath) {
+ Repository.Upload.TempPath = path.Join(AppWorkPath, Repository.Upload.TempPath)
+ }
+
+ if err := loadRepoArchiveFrom(rootCfg); err != nil {
+ log.Fatal("loadRepoArchiveFrom: %v", err)
+ }
+ Repository.EnableFlags = sec.Key("ENABLE_FLAGS").MustBool()
+}
diff --git a/modules/setting/repository_archive.go b/modules/setting/repository_archive.go
new file mode 100644
index 0000000..9d24afa
--- /dev/null
+++ b/modules/setting/repository_archive.go
@@ -0,0 +1,25 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import "fmt"
+
+var RepoArchive = struct {
+ Storage *Storage
+}{}
+
+func loadRepoArchiveFrom(rootCfg ConfigProvider) (err error) {
+ sec, _ := rootCfg.GetSection("repo-archive")
+ if sec == nil {
+ RepoArchive.Storage, err = getStorage(rootCfg, "repo-archive", "", nil)
+ return err
+ }
+
+ if err := sec.MapTo(&RepoArchive); err != nil {
+ return fmt.Errorf("mapto repoarchive failed: %v", err)
+ }
+
+ RepoArchive.Storage, err = getStorage(rootCfg, "repo-archive", "", sec)
+ return err
+}
diff --git a/modules/setting/repository_archive_test.go b/modules/setting/repository_archive_test.go
new file mode 100644
index 0000000..d3901b6
--- /dev/null
+++ b/modules/setting/repository_archive_test.go
@@ -0,0 +1,112 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getStorageInheritNameSectionTypeForRepoArchive(t *testing.T) {
+ // packages storage inherits from storage if nothing configured
+ iniStr := `
+[storage]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+
+ assert.EqualValues(t, "minio", RepoArchive.Storage.Type)
+ assert.EqualValues(t, "repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+
+ // we can also configure packages storage directly
+ iniStr = `
+[storage.repo-archive]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+
+ assert.EqualValues(t, "minio", RepoArchive.Storage.Type)
+ assert.EqualValues(t, "repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+
+ // or we can indicate the storage type in the packages section
+ iniStr = `
+[repo-archive]
+STORAGE_TYPE = my_minio
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+
+ assert.EqualValues(t, "minio", RepoArchive.Storage.Type)
+ assert.EqualValues(t, "repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+
+ // or we can indicate the storage type and minio base path in the packages section
+ iniStr = `
+[repo-archive]
+STORAGE_TYPE = my_minio
+MINIO_BASE_PATH = my_archive/
+
+[storage.my_minio]
+STORAGE_TYPE = minio
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+
+ assert.EqualValues(t, "minio", RepoArchive.Storage.Type)
+ assert.EqualValues(t, "my_archive/", RepoArchive.Storage.MinioConfig.BasePath)
+}
+
+func Test_RepoArchiveStorage(t *testing.T) {
+ iniStr := `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[storage]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ storage := RepoArchive.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+
+ iniStr = `
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[storage.repo-archive]
+STORAGE_TYPE = s3
+[storage.s3]
+STORAGE_TYPE = minio
+MINIO_ENDPOINT = s3.my-domain.net
+MINIO_BUCKET = gitea
+MINIO_LOCATION = homenet
+MINIO_USE_SSL = true
+MINIO_ACCESS_KEY_ID = correct_key
+MINIO_SECRET_ACCESS_KEY = correct_key
+`
+ cfg, err = NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ storage = RepoArchive.Storage
+
+ assert.EqualValues(t, "minio", storage.Type)
+ assert.EqualValues(t, "gitea", storage.MinioConfig.Bucket)
+}
diff --git a/modules/setting/security.go b/modules/setting/security.go
new file mode 100644
index 0000000..678a57c
--- /dev/null
+++ b/modules/setting/security.go
@@ -0,0 +1,173 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/url"
+ "os"
+ "strings"
+
+ "code.gitea.io/gitea/modules/auth/password/hash"
+ "code.gitea.io/gitea/modules/generate"
+ "code.gitea.io/gitea/modules/keying"
+ "code.gitea.io/gitea/modules/log"
+)
+
+var (
+ // Security settings
+ InstallLock bool
+ SecretKey string
+ InternalToken string // internal access token
+ LogInRememberDays int
+ CookieRememberName string
+ ReverseProxyAuthUser string
+ ReverseProxyAuthEmail string
+ ReverseProxyAuthFullName string
+ ReverseProxyLimit int
+ ReverseProxyTrustedProxies []string
+ MinPasswordLength int
+ ImportLocalPaths bool
+ DisableGitHooks bool
+ DisableWebhooks bool
+ OnlyAllowPushIfGiteaEnvironmentSet bool
+ PasswordComplexity []string
+ PasswordHashAlgo string
+ PasswordCheckPwn bool
+ SuccessfulTokensCacheSize int
+ DisableQueryAuthToken bool
+ CSRFCookieName = "_csrf"
+ CSRFCookieHTTPOnly = true
+)
+
+// loadSecret load the secret from ini by uriKey or verbatimKey, only one of them could be set
+// If the secret is loaded from uriKey (file), the file should be non-empty, to guarantee the behavior stable and clear.
+func loadSecret(sec ConfigSection, uriKey, verbatimKey string) string {
+ // don't allow setting both URI and verbatim string
+ uri := sec.Key(uriKey).String()
+ verbatim := sec.Key(verbatimKey).String()
+ if uri != "" && verbatim != "" {
+ log.Fatal("Cannot specify both %s and %s", uriKey, verbatimKey)
+ }
+
+ // if we have no URI, use verbatim
+ if uri == "" {
+ return verbatim
+ }
+
+ tempURI, err := url.Parse(uri)
+ if err != nil {
+ log.Fatal("Failed to parse %s (%s): %v", uriKey, uri, err)
+ }
+ switch tempURI.Scheme {
+ case "file":
+ buf, err := os.ReadFile(tempURI.RequestURI())
+ if err != nil {
+ log.Fatal("Failed to read %s (%s): %v", uriKey, tempURI.RequestURI(), err)
+ }
+ val := strings.TrimSpace(string(buf))
+ if val == "" {
+ // The file shouldn't be empty, otherwise we can not know whether the user has ever set the KEY or KEY_URI
+ // For example: if INTERNAL_TOKEN_URI=file:///empty-file,
+ // Then if the token is re-generated during installation and saved to INTERNAL_TOKEN
+ // Then INTERNAL_TOKEN and INTERNAL_TOKEN_URI both exist, that's a fatal error (they shouldn't)
+ log.Fatal("Failed to read %s (%s): the file is empty", uriKey, tempURI.RequestURI())
+ }
+ return val
+
+ // only file URIs are allowed
+ default:
+ log.Fatal("Unsupported URI-Scheme %q (%q = %q)", tempURI.Scheme, uriKey, uri)
+ return ""
+ }
+}
+
+// generateSaveInternalToken generates and saves the internal token to app.ini
+func generateSaveInternalToken(rootCfg ConfigProvider) {
+ token, err := generate.NewInternalToken()
+ if err != nil {
+ log.Fatal("Error generate internal token: %v", err)
+ }
+
+ InternalToken = token
+ saveCfg, err := rootCfg.PrepareSaving()
+ if err != nil {
+ log.Fatal("Error saving internal token: %v", err)
+ }
+ rootCfg.Section("security").Key("INTERNAL_TOKEN").SetValue(token)
+ saveCfg.Section("security").Key("INTERNAL_TOKEN").SetValue(token)
+ if err = saveCfg.Save(); err != nil {
+ log.Fatal("Error saving internal token: %v", err)
+ }
+}
+
+func loadSecurityFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("security")
+ InstallLock = HasInstallLock(rootCfg)
+ LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(31)
+ SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
+ if SecretKey == "" {
+ // FIXME: https://github.com/go-gitea/gitea/issues/16832
+ // Until it supports rotating an existing secret key, we shouldn't move users off of the widely used default value
+ SecretKey = "!#@FDEWREWR&*(" //nolint:gosec
+ }
+ keying.Init([]byte(SecretKey))
+
+ CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible")
+
+ ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER")
+ ReverseProxyAuthEmail = sec.Key("REVERSE_PROXY_AUTHENTICATION_EMAIL").MustString("X-WEBAUTH-EMAIL")
+ ReverseProxyAuthFullName = sec.Key("REVERSE_PROXY_AUTHENTICATION_FULL_NAME").MustString("X-WEBAUTH-FULLNAME")
+
+ ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1)
+ ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",")
+ if len(ReverseProxyTrustedProxies) == 0 {
+ ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"}
+ }
+
+ MinPasswordLength = sec.Key("MIN_PASSWORD_LENGTH").MustInt(8)
+ ImportLocalPaths = sec.Key("IMPORT_LOCAL_PATHS").MustBool(false)
+ DisableGitHooks = sec.Key("DISABLE_GIT_HOOKS").MustBool(true)
+ DisableWebhooks = sec.Key("DISABLE_WEBHOOKS").MustBool(false)
+ OnlyAllowPushIfGiteaEnvironmentSet = sec.Key("ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET").MustBool(true)
+
+ // Ensure that the provided default hash algorithm is a valid hash algorithm
+ var algorithm *hash.PasswordHashAlgorithm
+ PasswordHashAlgo, algorithm = hash.SetDefaultPasswordHashAlgorithm(sec.Key("PASSWORD_HASH_ALGO").MustString(""))
+ if algorithm == nil {
+ log.Fatal("The provided password hash algorithm was invalid: %s", sec.Key("PASSWORD_HASH_ALGO").MustString(""))
+ }
+
+ CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
+ PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
+ SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
+
+ InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
+ if InstallLock && InternalToken == "" {
+ // if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate
+ // some users do cluster deployment, they still depend on this auto-generating behavior.
+ generateSaveInternalToken(rootCfg)
+ }
+
+ cfgdata := sec.Key("PASSWORD_COMPLEXITY").Strings(",")
+ if len(cfgdata) == 0 {
+ cfgdata = []string{"off"}
+ }
+ PasswordComplexity = make([]string, 0, len(cfgdata))
+ for _, name := range cfgdata {
+ name := strings.ToLower(strings.Trim(name, `"`))
+ if name != "" {
+ PasswordComplexity = append(PasswordComplexity, name)
+ }
+ }
+
+ sectionHasDisableQueryAuthToken := sec.HasKey("DISABLE_QUERY_AUTH_TOKEN")
+
+ // TODO: default value should be true in future releases
+ DisableQueryAuthToken = sec.Key("DISABLE_QUERY_AUTH_TOKEN").MustBool(false)
+
+ // warn if the setting is set to false explicitly
+ if sectionHasDisableQueryAuthToken && !DisableQueryAuthToken {
+ log.Warn("Enabling Query API Auth tokens is not recommended. DISABLE_QUERY_AUTH_TOKEN will default to true in gitea 1.23 and will be removed in gitea 1.24.")
+ }
+}
diff --git a/modules/setting/server.go b/modules/setting/server.go
new file mode 100644
index 0000000..5cc33f6
--- /dev/null
+++ b/modules/setting/server.go
@@ -0,0 +1,368 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "encoding/base64"
+ "net"
+ "net/url"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Scheme describes protocol types
+type Scheme string
+
+// enumerates all the scheme types
+const (
+ HTTP Scheme = "http"
+ HTTPS Scheme = "https"
+ FCGI Scheme = "fcgi"
+ FCGIUnix Scheme = "fcgi+unix"
+ HTTPUnix Scheme = "http+unix"
+)
+
+// LandingPage describes the default page
+type LandingPage string
+
+// enumerates all the landing page types
+const (
+ LandingPageHome LandingPage = "/"
+ LandingPageExplore LandingPage = "/explore"
+ LandingPageOrganizations LandingPage = "/explore/organizations"
+ LandingPageLogin LandingPage = "/user/login"
+)
+
+var (
+ // AppName is the Application name, used in the page title.
+ // It maps to ini:"APP_NAME"
+ AppName string
+ // AppSlogan is the Application slogan.
+ // It maps to ini:"APP_SLOGAN"
+ AppSlogan string
+ // AppDisplayNameFormat defines how the AppDisplayName should be presented
+ // It maps to ini:"APP_DISPLAY_NAME_FORMAT"
+ AppDisplayNameFormat string
+ // AppDisplayName is the display name for the application, defined following AppDisplayNameFormat
+ AppDisplayName string
+ // AppURL is the Application ROOT_URL. It always has a '/' suffix
+ // It maps to ini:"ROOT_URL"
+ AppURL string
+ // AppSubURL represents the sub-url mounting point for gitea. It is either "" or starts with '/' and ends without '/', such as '/{subpath}'.
+ // This value is empty if site does not have sub-url.
+ AppSubURL string
+ // AppDataPath is the default path for storing data.
+ // It maps to ini:"APP_DATA_PATH" in [server] and defaults to AppWorkPath + "/data"
+ AppDataPath string
+ // LocalURL is the url for locally running applications to contact Gitea. It always has a '/' suffix
+ // It maps to ini:"LOCAL_ROOT_URL" in [server]
+ LocalURL string
+ // AssetVersion holds a opaque value that is used for cache-busting assets
+ AssetVersion string
+
+ // Server settings
+
+ Protocol Scheme
+ UseProxyProtocol bool // `ini:"USE_PROXY_PROTOCOL"`
+ ProxyProtocolTLSBridging bool //`ini:"PROXY_PROTOCOL_TLS_BRIDGING"`
+ ProxyProtocolHeaderTimeout time.Duration
+ ProxyProtocolAcceptUnknown bool
+ Domain string
+ HTTPAddr string
+ HTTPPort string
+ LocalUseProxyProtocol bool
+ RedirectOtherPort bool
+ RedirectorUseProxyProtocol bool
+ PortToRedirect string
+ OfflineMode bool
+ CertFile string
+ KeyFile string
+ StaticRootPath string
+ StaticCacheTime time.Duration
+ EnableGzip bool
+ LandingPageURL LandingPage
+ UnixSocketPermission uint32
+ EnablePprof bool
+ PprofDataPath string
+ EnableAcme bool
+ AcmeTOS bool
+ AcmeLiveDirectory string
+ AcmeEmail string
+ AcmeURL string
+ AcmeCARoot string
+ SSLMinimumVersion string
+ SSLMaximumVersion string
+ SSLCurvePreferences []string
+ SSLCipherSuites []string
+ GracefulRestartable bool
+ GracefulHammerTime time.Duration
+ StartupTimeout time.Duration
+ PerWriteTimeout = 30 * time.Second
+ PerWritePerKbTimeout = 10 * time.Second
+ StaticURLPrefix string
+ AbsoluteAssetURL string
+
+ ManifestData string
+)
+
+// MakeManifestData generates web app manifest JSON
+func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte {
+ type manifestIcon struct {
+ Src string `json:"src"`
+ Type string `json:"type"`
+ Sizes string `json:"sizes"`
+ }
+
+ type manifestJSON struct {
+ Name string `json:"name"`
+ ShortName string `json:"short_name"`
+ StartURL string `json:"start_url"`
+ Icons []manifestIcon `json:"icons"`
+ }
+
+ bytes, err := json.Marshal(&manifestJSON{
+ Name: appName,
+ ShortName: appName,
+ StartURL: appURL,
+ Icons: []manifestIcon{
+ {
+ Src: absoluteAssetURL + "/assets/img/logo.png",
+ Type: "image/png",
+ Sizes: "512x512",
+ },
+ {
+ Src: absoluteAssetURL + "/assets/img/logo.svg",
+ Type: "image/svg+xml",
+ Sizes: "512x512",
+ },
+ },
+ })
+ if err != nil {
+ log.Error("unable to marshal manifest JSON. Error: %v", err)
+ return make([]byte, 0)
+ }
+
+ return bytes
+}
+
+// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash
+func MakeAbsoluteAssetURL(appURL, staticURLPrefix string) string {
+ parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/"))
+ if err != nil {
+ log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err)
+ }
+
+ if err == nil && parsedPrefix.Hostname() == "" {
+ if staticURLPrefix == "" {
+ return strings.TrimSuffix(appURL, "/")
+ }
+
+ // StaticURLPrefix is just a path
+ return util.URLJoin(appURL, strings.TrimSuffix(staticURLPrefix, "/"))
+ }
+
+ return strings.TrimSuffix(staticURLPrefix, "/")
+}
+
+func generateDisplayName() string {
+ appDisplayName := AppName
+ if AppSlogan != "" {
+ appDisplayName = strings.Replace(AppDisplayNameFormat, "{APP_NAME}", AppName, 1)
+ appDisplayName = strings.Replace(appDisplayName, "{APP_SLOGAN}", AppSlogan, 1)
+ }
+ return appDisplayName
+}
+
+func loadServerFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("server")
+ AppName = rootCfg.Section("").Key("APP_NAME").MustString("Forgejo: Beyond coding. We Forge.")
+ AppSlogan = rootCfg.Section("").Key("APP_SLOGAN").MustString("")
+ AppDisplayNameFormat = rootCfg.Section("").Key("APP_DISPLAY_NAME_FORMAT").MustString("{APP_NAME}: {APP_SLOGAN}")
+ AppDisplayName = generateDisplayName()
+ Domain = sec.Key("DOMAIN").MustString("localhost")
+ HTTPAddr = sec.Key("HTTP_ADDR").MustString("0.0.0.0")
+ HTTPPort = sec.Key("HTTP_PORT").MustString("3000")
+
+ Protocol = HTTP
+ protocolCfg := sec.Key("PROTOCOL").String()
+ switch protocolCfg {
+ case "https":
+ Protocol = HTTPS
+
+ // DEPRECATED should not be removed because users maybe upgrade from lower version to the latest version
+ // if these are removed, the warning will not be shown
+ if sec.HasKey("ENABLE_ACME") {
+ EnableAcme = sec.Key("ENABLE_ACME").MustBool(false)
+ } else {
+ deprecatedSetting(rootCfg, "server", "ENABLE_LETSENCRYPT", "server", "ENABLE_ACME", "v1.19.0")
+ EnableAcme = sec.Key("ENABLE_LETSENCRYPT").MustBool(false)
+ }
+ if EnableAcme {
+ AcmeURL = sec.Key("ACME_URL").MustString("")
+ AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("")
+
+ if sec.HasKey("ACME_ACCEPTTOS") {
+ AcmeTOS = sec.Key("ACME_ACCEPTTOS").MustBool(false)
+ } else {
+ deprecatedSetting(rootCfg, "server", "LETSENCRYPT_ACCEPTTOS", "server", "ACME_ACCEPTTOS", "v1.19.0")
+ AcmeTOS = sec.Key("LETSENCRYPT_ACCEPTTOS").MustBool(false)
+ }
+ if !AcmeTOS {
+ log.Fatal("ACME TOS is not accepted (ACME_ACCEPTTOS).")
+ }
+
+ if sec.HasKey("ACME_DIRECTORY") {
+ AcmeLiveDirectory = sec.Key("ACME_DIRECTORY").MustString("https")
+ } else {
+ deprecatedSetting(rootCfg, "server", "LETSENCRYPT_DIRECTORY", "server", "ACME_DIRECTORY", "v1.19.0")
+ AcmeLiveDirectory = sec.Key("LETSENCRYPT_DIRECTORY").MustString("https")
+ }
+
+ if sec.HasKey("ACME_EMAIL") {
+ AcmeEmail = sec.Key("ACME_EMAIL").MustString("")
+ } else {
+ deprecatedSetting(rootCfg, "server", "LETSENCRYPT_EMAIL", "server", "ACME_EMAIL", "v1.19.0")
+ AcmeEmail = sec.Key("LETSENCRYPT_EMAIL").MustString("")
+ }
+ } else {
+ CertFile = sec.Key("CERT_FILE").String()
+ KeyFile = sec.Key("KEY_FILE").String()
+ if len(CertFile) > 0 && !filepath.IsAbs(CertFile) {
+ CertFile = filepath.Join(CustomPath, CertFile)
+ }
+ if len(KeyFile) > 0 && !filepath.IsAbs(KeyFile) {
+ KeyFile = filepath.Join(CustomPath, KeyFile)
+ }
+ }
+ SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("")
+ SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("")
+ SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",")
+ SSLCipherSuites = sec.Key("SSL_CIPHER_SUITES").Strings(",")
+ case "fcgi":
+ Protocol = FCGI
+ case "fcgi+unix", "unix", "http+unix":
+ switch protocolCfg {
+ case "fcgi+unix":
+ Protocol = FCGIUnix
+ case "unix":
+ log.Warn("unix PROTOCOL value is deprecated, please use http+unix")
+ fallthrough
+ case "http+unix":
+ Protocol = HTTPUnix
+ }
+ UnixSocketPermissionRaw := sec.Key("UNIX_SOCKET_PERMISSION").MustString("666")
+ UnixSocketPermissionParsed, err := strconv.ParseUint(UnixSocketPermissionRaw, 8, 32)
+ if err != nil || UnixSocketPermissionParsed > 0o777 {
+ log.Fatal("Failed to parse unixSocketPermission: %s", UnixSocketPermissionRaw)
+ }
+
+ UnixSocketPermission = uint32(UnixSocketPermissionParsed)
+ if !filepath.IsAbs(HTTPAddr) {
+ HTTPAddr = filepath.Join(AppWorkPath, HTTPAddr)
+ }
+ }
+ UseProxyProtocol = sec.Key("USE_PROXY_PROTOCOL").MustBool(false)
+ ProxyProtocolTLSBridging = sec.Key("PROXY_PROTOCOL_TLS_BRIDGING").MustBool(false)
+ ProxyProtocolHeaderTimeout = sec.Key("PROXY_PROTOCOL_HEADER_TIMEOUT").MustDuration(5 * time.Second)
+ ProxyProtocolAcceptUnknown = sec.Key("PROXY_PROTOCOL_ACCEPT_UNKNOWN").MustBool(false)
+ GracefulRestartable = sec.Key("ALLOW_GRACEFUL_RESTARTS").MustBool(true)
+ GracefulHammerTime = sec.Key("GRACEFUL_HAMMER_TIME").MustDuration(60 * time.Second)
+ StartupTimeout = sec.Key("STARTUP_TIMEOUT").MustDuration(0 * time.Second)
+ PerWriteTimeout = sec.Key("PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout)
+ PerWritePerKbTimeout = sec.Key("PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout)
+
+ defaultAppURL := string(Protocol) + "://" + Domain + ":" + HTTPPort
+ AppURL = sec.Key("ROOT_URL").MustString(defaultAppURL)
+
+ // Check validity of AppURL
+ appURL, err := url.Parse(AppURL)
+ if err != nil {
+ log.Fatal("Invalid ROOT_URL '%s': %s", AppURL, err)
+ }
+ // Remove default ports from AppURL.
+ // (scheme-based URL normalization, RFC 3986 section 6.2.3)
+ if (appURL.Scheme == string(HTTP) && appURL.Port() == "80") || (appURL.Scheme == string(HTTPS) && appURL.Port() == "443") {
+ appURL.Host = appURL.Hostname()
+ }
+ // This should be TrimRight to ensure that there is only a single '/' at the end of AppURL.
+ AppURL = strings.TrimRight(appURL.String(), "/") + "/"
+
+ // Suburl should start with '/' and end without '/', such as '/{subpath}'.
+ // This value is empty if site does not have sub-url.
+ AppSubURL = strings.TrimSuffix(appURL.Path, "/")
+ StaticURLPrefix = strings.TrimSuffix(sec.Key("STATIC_URL_PREFIX").MustString(AppSubURL), "/")
+
+ // Check if Domain differs from AppURL domain than update it to AppURL's domain
+ urlHostname := appURL.Hostname()
+ if urlHostname != Domain && net.ParseIP(urlHostname) == nil && urlHostname != "" {
+ Domain = urlHostname
+ }
+
+ AbsoluteAssetURL = MakeAbsoluteAssetURL(AppURL, StaticURLPrefix)
+ AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed)
+
+ manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
+ ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
+
+ var defaultLocalURL string
+ switch Protocol {
+ case HTTPUnix:
+ defaultLocalURL = "http://unix/"
+ case FCGI:
+ defaultLocalURL = AppURL
+ case FCGIUnix:
+ defaultLocalURL = AppURL
+ default:
+ defaultLocalURL = string(Protocol) + "://"
+ if HTTPAddr == "0.0.0.0" {
+ defaultLocalURL += net.JoinHostPort("localhost", HTTPPort) + "/"
+ } else {
+ defaultLocalURL += net.JoinHostPort(HTTPAddr, HTTPPort) + "/"
+ }
+ }
+ LocalURL = sec.Key("LOCAL_ROOT_URL").MustString(defaultLocalURL)
+ LocalURL = strings.TrimRight(LocalURL, "/") + "/"
+ LocalUseProxyProtocol = sec.Key("LOCAL_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol)
+ RedirectOtherPort = sec.Key("REDIRECT_OTHER_PORT").MustBool(false)
+ PortToRedirect = sec.Key("PORT_TO_REDIRECT").MustString("80")
+ RedirectorUseProxyProtocol = sec.Key("REDIRECTOR_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol)
+ OfflineMode = sec.Key("OFFLINE_MODE").MustBool(true)
+ if len(StaticRootPath) == 0 {
+ StaticRootPath = AppWorkPath
+ }
+ StaticRootPath = sec.Key("STATIC_ROOT_PATH").MustString(StaticRootPath)
+ StaticCacheTime = sec.Key("STATIC_CACHE_TIME").MustDuration(6 * time.Hour)
+ AppDataPath = sec.Key("APP_DATA_PATH").MustString(path.Join(AppWorkPath, "data"))
+ if !filepath.IsAbs(AppDataPath) {
+ AppDataPath = filepath.ToSlash(filepath.Join(AppWorkPath, AppDataPath))
+ }
+
+ EnableGzip = sec.Key("ENABLE_GZIP").MustBool()
+ EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false)
+ PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(path.Join(AppWorkPath, "data/tmp/pprof"))
+ if !filepath.IsAbs(PprofDataPath) {
+ PprofDataPath = filepath.Join(AppWorkPath, PprofDataPath)
+ }
+
+ landingPage := sec.Key("LANDING_PAGE").MustString("home")
+ switch landingPage {
+ case "explore":
+ LandingPageURL = LandingPageExplore
+ case "organizations":
+ LandingPageURL = LandingPageOrganizations
+ case "login":
+ LandingPageURL = LandingPageLogin
+ case "", "home":
+ LandingPageURL = LandingPageHome
+ default:
+ LandingPageURL = LandingPage(landingPage)
+ }
+}
diff --git a/modules/setting/server_test.go b/modules/setting/server_test.go
new file mode 100644
index 0000000..8db8168
--- /dev/null
+++ b/modules/setting/server_test.go
@@ -0,0 +1,36 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDisplayNameDefault(t *testing.T) {
+ defer test.MockVariableValue(&AppName, "Forgejo")()
+ defer test.MockVariableValue(&AppSlogan, "Beyond coding. We Forge.")()
+ defer test.MockVariableValue(&AppDisplayNameFormat, "{APP_NAME}: {APP_SLOGAN}")()
+ displayName := generateDisplayName()
+ assert.Equal(t, "Forgejo: Beyond coding. We Forge.", displayName)
+}
+
+func TestDisplayNameEmptySlogan(t *testing.T) {
+ defer test.MockVariableValue(&AppName, "Forgejo")()
+ defer test.MockVariableValue(&AppSlogan, "")()
+ defer test.MockVariableValue(&AppDisplayNameFormat, "{APP_NAME}: {APP_SLOGAN}")()
+ displayName := generateDisplayName()
+ assert.Equal(t, "Forgejo", displayName)
+}
+
+func TestDisplayNameCustomFormat(t *testing.T) {
+ defer test.MockVariableValue(&AppName, "Forgejo")()
+ defer test.MockVariableValue(&AppSlogan, "Beyond coding. We Forge.")()
+ defer test.MockVariableValue(&AppDisplayNameFormat, "{APP_NAME} - {APP_SLOGAN}")()
+ displayName := generateDisplayName()
+ assert.Equal(t, "Forgejo - Beyond coding. We Forge.", displayName)
+}
diff --git a/modules/setting/service.go b/modules/setting/service.go
new file mode 100644
index 0000000..afaee18
--- /dev/null
+++ b/modules/setting/service.go
@@ -0,0 +1,262 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "regexp"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/structs"
+
+ "github.com/gobwas/glob"
+)
+
+// enumerates all the types of captchas
+const (
+ ImageCaptcha = "image"
+ ReCaptcha = "recaptcha"
+ HCaptcha = "hcaptcha"
+ MCaptcha = "mcaptcha"
+ CfTurnstile = "cfturnstile"
+)
+
+// Service settings
+var Service = struct {
+ DefaultUserVisibility string
+ DefaultUserVisibilityMode structs.VisibleType
+ AllowedUserVisibilityModes []string
+ AllowedUserVisibilityModesSlice AllowedVisibility `ini:"-"`
+ DefaultOrgVisibility string
+ DefaultOrgVisibilityMode structs.VisibleType
+ ActiveCodeLives int
+ ResetPwdCodeLives int
+ RegisterEmailConfirm bool
+ RegisterManualConfirm bool
+ EmailDomainAllowList []glob.Glob
+ EmailDomainBlockList []glob.Glob
+ DisableRegistration bool
+ AllowOnlyInternalRegistration bool
+ AllowOnlyExternalRegistration bool
+ ShowRegistrationButton bool
+ ShowMilestonesDashboardPage bool
+ RequireSignInView bool
+ EnableNotifyMail bool
+ EnableBasicAuth bool
+ EnableReverseProxyAuth bool
+ EnableReverseProxyAuthAPI bool
+ EnableReverseProxyAutoRegister bool
+ EnableReverseProxyEmail bool
+ EnableReverseProxyFullName bool
+ EnableCaptcha bool
+ RequireCaptchaForLogin bool
+ RequireExternalRegistrationCaptcha bool
+ RequireExternalRegistrationPassword bool
+ CaptchaType string
+ RecaptchaSecret string
+ RecaptchaSitekey string
+ RecaptchaURL string
+ CfTurnstileSecret string
+ CfTurnstileSitekey string
+ HcaptchaSecret string
+ HcaptchaSitekey string
+ McaptchaSecret string
+ McaptchaSitekey string
+ McaptchaURL string
+ DefaultKeepEmailPrivate bool
+ DefaultAllowCreateOrganization bool
+ DefaultUserIsRestricted bool
+ AllowDotsInUsernames bool
+ EnableTimetracking bool
+ DefaultEnableTimetracking bool
+ DefaultEnableDependencies bool
+ AllowCrossRepositoryDependencies bool
+ DefaultAllowOnlyContributorsToTrackTime bool
+ NoReplyAddress string
+ UserLocationMapURL string
+ EnableUserHeatmap bool
+ AutoWatchNewRepos bool
+ AutoWatchOnChanges bool
+ DefaultOrgMemberVisible bool
+ UserDeleteWithCommentsMaxTime time.Duration
+ ValidSiteURLSchemes []string
+
+ // OpenID settings
+ EnableOpenIDSignIn bool
+ EnableOpenIDSignUp bool
+ OpenIDWhitelist []*regexp.Regexp
+ OpenIDBlacklist []*regexp.Regexp
+
+ // Explore page settings
+ Explore struct {
+ RequireSigninView bool `ini:"REQUIRE_SIGNIN_VIEW"`
+ DisableUsersPage bool `ini:"DISABLE_USERS_PAGE"`
+ } `ini:"service.explore"`
+}{
+ AllowedUserVisibilityModesSlice: []bool{true, true, true},
+}
+
+// AllowedVisibility store in a 3 item bool array what is allowed
+type AllowedVisibility []bool
+
+// IsAllowedVisibility check if a AllowedVisibility allow a specific VisibleType
+func (a AllowedVisibility) IsAllowedVisibility(t structs.VisibleType) bool {
+ if int(t) >= len(a) {
+ return false
+ }
+ return a[t]
+}
+
+// ToVisibleTypeSlice convert a AllowedVisibility into a VisibleType slice
+func (a AllowedVisibility) ToVisibleTypeSlice() (result []structs.VisibleType) {
+ for i, v := range a {
+ if v {
+ result = append(result, structs.VisibleType(i))
+ }
+ }
+ return result
+}
+
+func CompileEmailGlobList(sec ConfigSection, keys ...string) (globs []glob.Glob) {
+ for _, key := range keys {
+ list := sec.Key(key).Strings(",")
+ for _, s := range list {
+ if g, err := glob.Compile(s); err == nil {
+ globs = append(globs, g)
+ } else {
+ log.Error("Skip invalid email allow/block list expression %q: %v", s, err)
+ }
+ }
+ }
+ return globs
+}
+
+func loadServiceFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("service")
+ Service.ActiveCodeLives = sec.Key("ACTIVE_CODE_LIVE_MINUTES").MustInt(180)
+ Service.ResetPwdCodeLives = sec.Key("RESET_PASSWD_CODE_LIVE_MINUTES").MustInt(180)
+ Service.DisableRegistration = sec.Key("DISABLE_REGISTRATION").MustBool()
+ Service.AllowOnlyInternalRegistration = sec.Key("ALLOW_ONLY_INTERNAL_REGISTRATION").MustBool()
+ Service.AllowOnlyExternalRegistration = sec.Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").MustBool()
+ if Service.AllowOnlyExternalRegistration && Service.AllowOnlyInternalRegistration {
+ log.Warn("ALLOW_ONLY_INTERNAL_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are true - disabling registration")
+ Service.DisableRegistration = true
+ }
+ if !sec.Key("REGISTER_EMAIL_CONFIRM").MustBool() {
+ Service.RegisterManualConfirm = sec.Key("REGISTER_MANUAL_CONFIRM").MustBool(false)
+ } else {
+ Service.RegisterManualConfirm = false
+ }
+ if sec.HasKey("EMAIL_DOMAIN_WHITELIST") {
+ deprecatedSetting(rootCfg, "service", "EMAIL_DOMAIN_WHITELIST", "service", "EMAIL_DOMAIN_ALLOWLIST", "1.21")
+ }
+ Service.EmailDomainAllowList = CompileEmailGlobList(sec, "EMAIL_DOMAIN_WHITELIST", "EMAIL_DOMAIN_ALLOWLIST")
+ Service.EmailDomainBlockList = CompileEmailGlobList(sec, "EMAIL_DOMAIN_BLOCKLIST")
+ Service.ShowRegistrationButton = sec.Key("SHOW_REGISTRATION_BUTTON").MustBool(!(Service.DisableRegistration || Service.AllowOnlyExternalRegistration))
+ Service.ShowMilestonesDashboardPage = sec.Key("SHOW_MILESTONES_DASHBOARD_PAGE").MustBool(true)
+ Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool()
+ Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true)
+ Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool()
+ Service.EnableReverseProxyAuthAPI = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION_API").MustBool()
+ Service.EnableReverseProxyAutoRegister = sec.Key("ENABLE_REVERSE_PROXY_AUTO_REGISTRATION").MustBool()
+ Service.EnableReverseProxyEmail = sec.Key("ENABLE_REVERSE_PROXY_EMAIL").MustBool()
+ Service.EnableReverseProxyFullName = sec.Key("ENABLE_REVERSE_PROXY_FULL_NAME").MustBool()
+ Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool(false)
+ Service.RequireCaptchaForLogin = sec.Key("REQUIRE_CAPTCHA_FOR_LOGIN").MustBool(false)
+ Service.RequireExternalRegistrationCaptcha = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_CAPTCHA").MustBool(Service.EnableCaptcha)
+ Service.RequireExternalRegistrationPassword = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_PASSWORD").MustBool()
+ Service.CaptchaType = sec.Key("CAPTCHA_TYPE").MustString(ImageCaptcha)
+ Service.RecaptchaSecret = sec.Key("RECAPTCHA_SECRET").MustString("")
+ Service.RecaptchaSitekey = sec.Key("RECAPTCHA_SITEKEY").MustString("")
+ Service.RecaptchaURL = sec.Key("RECAPTCHA_URL").MustString("https://www.google.com/recaptcha/")
+ Service.CfTurnstileSecret = sec.Key("CF_TURNSTILE_SECRET").MustString("")
+ Service.CfTurnstileSitekey = sec.Key("CF_TURNSTILE_SITEKEY").MustString("")
+ Service.HcaptchaSecret = sec.Key("HCAPTCHA_SECRET").MustString("")
+ Service.HcaptchaSitekey = sec.Key("HCAPTCHA_SITEKEY").MustString("")
+ Service.McaptchaURL = sec.Key("MCAPTCHA_URL").MustString("https://demo.mcaptcha.org/")
+ Service.McaptchaSecret = sec.Key("MCAPTCHA_SECRET").MustString("")
+ Service.McaptchaSitekey = sec.Key("MCAPTCHA_SITEKEY").MustString("")
+ Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
+ Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
+ Service.DefaultUserIsRestricted = sec.Key("DEFAULT_USER_IS_RESTRICTED").MustBool(false)
+ Service.AllowDotsInUsernames = sec.Key("ALLOW_DOTS_IN_USERNAMES").MustBool(true)
+ Service.EnableTimetracking = sec.Key("ENABLE_TIMETRACKING").MustBool(true)
+ if Service.EnableTimetracking {
+ Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true)
+ }
+ Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true)
+ Service.AllowCrossRepositoryDependencies = sec.Key("ALLOW_CROSS_REPOSITORY_DEPENDENCIES").MustBool(true)
+ Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true)
+ Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply." + Domain)
+ Service.UserLocationMapURL = sec.Key("USER_LOCATION_MAP_URL").MustString("https://www.openstreetmap.org/search?query=")
+ Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
+ Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true)
+ Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false)
+ modes := sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",")
+ if len(modes) != 0 {
+ Service.AllowedUserVisibilityModes = []string{}
+ Service.AllowedUserVisibilityModesSlice = []bool{false, false, false}
+ for _, sMode := range modes {
+ if tp, ok := structs.VisibilityModes[sMode]; ok { // remove unsupported modes
+ Service.AllowedUserVisibilityModes = append(Service.AllowedUserVisibilityModes, sMode)
+ Service.AllowedUserVisibilityModesSlice[tp] = true
+ } else {
+ log.Warn("ALLOWED_USER_VISIBILITY_MODES %s is unsupported", sMode)
+ }
+ }
+ }
+
+ if len(Service.AllowedUserVisibilityModes) == 0 {
+ Service.AllowedUserVisibilityModes = []string{"public", "limited", "private"}
+ Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
+ }
+
+ Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").String()
+ if Service.DefaultUserVisibility == "" {
+ Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
+ } else if !Service.AllowedUserVisibilityModesSlice[structs.VisibilityModes[Service.DefaultUserVisibility]] {
+ log.Warn("DEFAULT_USER_VISIBILITY %s is wrong or not in ALLOWED_USER_VISIBILITY_MODES, using first allowed", Service.DefaultUserVisibility)
+ Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
+ }
+ Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility]
+ Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes))
+ Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility]
+ Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool()
+ Service.UserDeleteWithCommentsMaxTime = sec.Key("USER_DELETE_WITH_COMMENTS_MAX_TIME").MustDuration(0)
+ sec.Key("VALID_SITE_URL_SCHEMES").MustString("http,https")
+ Service.ValidSiteURLSchemes = sec.Key("VALID_SITE_URL_SCHEMES").Strings(",")
+ schemes := make([]string, 0, len(Service.ValidSiteURLSchemes))
+ for _, scheme := range Service.ValidSiteURLSchemes {
+ scheme = strings.ToLower(strings.TrimSpace(scheme))
+ if scheme != "" {
+ schemes = append(schemes, scheme)
+ }
+ }
+ Service.ValidSiteURLSchemes = schemes
+
+ mustMapSetting(rootCfg, "service.explore", &Service.Explore)
+
+ loadOpenIDSetting(rootCfg)
+}
+
+func loadOpenIDSetting(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("openid")
+ Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock)
+ Service.EnableOpenIDSignUp = sec.Key("ENABLE_OPENID_SIGNUP").MustBool(!Service.DisableRegistration && Service.EnableOpenIDSignIn)
+ pats := sec.Key("WHITELISTED_URIS").Strings(" ")
+ if len(pats) != 0 {
+ Service.OpenIDWhitelist = make([]*regexp.Regexp, len(pats))
+ for i, p := range pats {
+ Service.OpenIDWhitelist[i] = regexp.MustCompilePOSIX(p)
+ }
+ }
+ pats = sec.Key("BLACKLISTED_URIS").Strings(" ")
+ if len(pats) != 0 {
+ Service.OpenIDBlacklist = make([]*regexp.Regexp, len(pats))
+ for i, p := range pats {
+ Service.OpenIDBlacklist[i] = regexp.MustCompilePOSIX(p)
+ }
+ }
+}
diff --git a/modules/setting/service_test.go b/modules/setting/service_test.go
new file mode 100644
index 0000000..7a13e39
--- /dev/null
+++ b/modules/setting/service_test.go
@@ -0,0 +1,133 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/structs"
+
+ "github.com/gobwas/glob"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoadServices(t *testing.T) {
+ oldService := Service
+ defer func() {
+ Service = oldService
+ }()
+
+ cfg, err := NewConfigProviderFromData(`
+[service]
+EMAIL_DOMAIN_WHITELIST = d1, *.w
+EMAIL_DOMAIN_ALLOWLIST = d2, *.a
+EMAIL_DOMAIN_BLOCKLIST = d3, *.b
+`)
+ require.NoError(t, err)
+ loadServiceFrom(cfg)
+
+ match := func(globs []glob.Glob, s string) bool {
+ for _, g := range globs {
+ if g.Match(s) {
+ return true
+ }
+ }
+ return false
+ }
+
+ assert.True(t, match(Service.EmailDomainAllowList, "d1"))
+ assert.True(t, match(Service.EmailDomainAllowList, "foo.w"))
+ assert.True(t, match(Service.EmailDomainAllowList, "d2"))
+ assert.True(t, match(Service.EmailDomainAllowList, "foo.a"))
+ assert.False(t, match(Service.EmailDomainAllowList, "d3"))
+
+ assert.True(t, match(Service.EmailDomainBlockList, "d3"))
+ assert.True(t, match(Service.EmailDomainBlockList, "foo.b"))
+ assert.False(t, match(Service.EmailDomainBlockList, "d1"))
+}
+
+func TestLoadServiceVisibilityModes(t *testing.T) {
+ oldService := Service
+ defer func() {
+ Service = oldService
+ }()
+
+ kases := map[string]func(){
+ `
+[service]
+DEFAULT_USER_VISIBILITY = public
+ALLOWED_USER_VISIBILITY_MODES = public,limited,private
+`: func() {
+ assert.Equal(t, "public", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+ [service]
+ DEFAULT_USER_VISIBILITY = public
+ `: func() {
+ assert.Equal(t, "public", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+ [service]
+ DEFAULT_USER_VISIBILITY = limited
+ `: func() {
+ assert.Equal(t, "limited", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+[service]
+ALLOWED_USER_VISIBILITY_MODES = public,limited,private
+`: func() {
+ assert.Equal(t, "public", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+[service]
+DEFAULT_USER_VISIBILITY = public
+ALLOWED_USER_VISIBILITY_MODES = limited,private
+`: func() {
+ assert.Equal(t, "limited", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+[service]
+DEFAULT_USER_VISIBILITY = my_type
+ALLOWED_USER_VISIBILITY_MODES = limited,private
+`: func() {
+ assert.Equal(t, "limited", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes)
+ },
+ `
+[service]
+DEFAULT_USER_VISIBILITY = public
+ALLOWED_USER_VISIBILITY_MODES = public, limit, privated
+`: func() {
+ assert.Equal(t, "public", Service.DefaultUserVisibility)
+ assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
+ assert.Equal(t, []string{"public"}, Service.AllowedUserVisibilityModes)
+ },
+ }
+
+ for kase, fun := range kases {
+ t.Run(kase, func(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(kase)
+ require.NoError(t, err)
+ loadServiceFrom(cfg)
+ fun()
+ // reset
+ Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
+ Service.AllowedUserVisibilityModes = []string{}
+ Service.DefaultUserVisibility = ""
+ Service.DefaultUserVisibilityMode = structs.VisibleTypePublic
+ })
+ }
+}
diff --git a/modules/setting/session.go b/modules/setting/session.go
new file mode 100644
index 0000000..e9637fd
--- /dev/null
+++ b/modules/setting/session.go
@@ -0,0 +1,78 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// SessionConfig defines Session settings
+var SessionConfig = struct {
+ OriginalProvider string
+ Provider string
+ // Provider configuration, it's corresponding to provider.
+ ProviderConfig string
+ // Cookie name to save session ID. Default is "MacaronSession".
+ CookieName string
+ // Cookie path to store. Default is "/".
+ CookiePath string
+ // GC interval time in seconds. Default is 3600.
+ Gclifetime int64
+ // Max life time in seconds. Default is whatever GC interval time is.
+ Maxlifetime int64
+ // Use HTTPS only. Default is false.
+ Secure bool
+ // Cookie domain name. Default is empty.
+ Domain string
+ // SameSite declares if your cookie should be restricted to a first-party or same-site context. Valid strings are "none", "lax", "strict". Default is "lax"
+ SameSite http.SameSite
+}{
+ CookieName: "i_like_gitea",
+ Gclifetime: 86400,
+ Maxlifetime: 86400,
+ SameSite: http.SameSiteLaxMode,
+}
+
+func loadSessionFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("session")
+ SessionConfig.Provider = sec.Key("PROVIDER").In("memory",
+ []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"})
+ SessionConfig.ProviderConfig = strings.Trim(sec.Key("PROVIDER_CONFIG").MustString(path.Join(AppDataPath, "sessions")), "\" ")
+ if SessionConfig.Provider == "file" && !filepath.IsAbs(SessionConfig.ProviderConfig) {
+ SessionConfig.ProviderConfig = path.Join(AppWorkPath, SessionConfig.ProviderConfig)
+ }
+ SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea")
+ SessionConfig.CookiePath = AppSubURL
+ if SessionConfig.CookiePath == "" {
+ SessionConfig.CookiePath = "/"
+ }
+ SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://"))
+ SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400)
+ SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400)
+ SessionConfig.Domain = sec.Key("DOMAIN").String()
+ samesiteString := sec.Key("SAME_SITE").In("lax", []string{"none", "lax", "strict"})
+ switch strings.ToLower(samesiteString) {
+ case "none":
+ SessionConfig.SameSite = http.SameSiteNoneMode
+ case "strict":
+ SessionConfig.SameSite = http.SameSiteStrictMode
+ default:
+ SessionConfig.SameSite = http.SameSiteLaxMode
+ }
+ shadowConfig, err := json.Marshal(SessionConfig)
+ if err != nil {
+ log.Fatal("Can't shadow session config: %v", err)
+ }
+ SessionConfig.ProviderConfig = string(shadowConfig)
+ SessionConfig.OriginalProvider = SessionConfig.Provider
+ SessionConfig.Provider = "VirtualSession"
+
+ log.Info("Session Service Enabled")
+}
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
new file mode 100644
index 0000000..c9d3083
--- /dev/null
+++ b/modules/setting/setting.go
@@ -0,0 +1,238 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/user"
+ "code.gitea.io/gitea/modules/util"
+)
+
+var ForgejoVersion = "1.0.0"
+
+// settings
+var (
+ // AppVer is the version of the current build of Gitea. It is set in main.go from main.Version.
+ AppVer string
+ // AppBuiltWith represents a human-readable version go runtime build version and build tags. (See main.go formatBuiltWith().)
+ AppBuiltWith string
+ // AppStartTime store time gitea has started
+ AppStartTime time.Time
+
+ // Other global setting objects
+
+ CfgProvider ConfigProvider
+ RunMode string
+ RunUser string
+ IsProd bool
+ IsWindows bool
+
+ // IsInTesting indicates whether the testing is running. A lot of unreliable code causes a lot of nonsense error logs during testing
+ // TODO: this is only a temporary solution, we should make the test code more reliable
+ IsInTesting = false
+)
+
+func init() {
+ IsWindows = runtime.GOOS == "windows"
+ if AppVer == "" {
+ AppVer = "dev"
+ }
+
+ // We can rely on log.CanColorStdout being set properly because modules/log/console_windows.go comes before modules/setting/setting.go lexicographically
+ // By default set this logger at Info - we'll change it later, but we need to start with something.
+ log.SetConsoleLogger(log.DEFAULT, "console", log.INFO)
+}
+
+// IsRunUserMatchCurrentUser returns false if configured run user does not match
+// actual user that runs the app. The first return value is the actual user name.
+// This check is ignored under Windows since SSH remote login is not the main
+// method to login on Windows.
+func IsRunUserMatchCurrentUser(runUser string) (string, bool) {
+ if IsWindows || SSH.StartBuiltinServer {
+ return "", true
+ }
+
+ currentUser := user.CurrentUsername()
+ return currentUser, runUser == currentUser
+}
+
+// PrepareAppDataPath creates app data directory if necessary
+func PrepareAppDataPath() error {
+ // FIXME: There are too many calls to MkdirAll in old code. It is incorrect.
+ // For example, if someDir=/mnt/vol1/gitea-home/data, if the mount point /mnt/vol1 is not mounted when Forgejo runs,
+ // then Forgejo will make new empty directories in /mnt/vol1, all are stored in the root filesystem.
+ // The correct behavior should be: creating parent directories is end users' duty. We only create sub-directories in existing parent directories.
+ // For quickstart, the parent directories should be created automatically for first startup (eg: a flag or a check of INSTALL_LOCK).
+ // Now we can take the first step to do correctly (using Mkdir) in other packages, and prepare the AppDataPath here, then make a refactor in future.
+
+ st, err := os.Stat(AppDataPath)
+ if os.IsNotExist(err) {
+ err = os.MkdirAll(AppDataPath, os.ModePerm)
+ if err != nil {
+ return fmt.Errorf("unable to create the APP_DATA_PATH directory: %q, Error: %w", AppDataPath, err)
+ }
+ return nil
+ }
+
+ if err != nil {
+ return fmt.Errorf("unable to use APP_DATA_PATH %q. Error: %w", AppDataPath, err)
+ }
+
+ if !st.IsDir() /* also works for symlink */ {
+ return fmt.Errorf("the APP_DATA_PATH %q is not a directory (or symlink to a directory) and can't be used", AppDataPath)
+ }
+
+ return nil
+}
+
+func InitCfgProvider(file string) {
+ var err error
+ if CfgProvider, err = NewConfigProviderFromFile(file); err != nil {
+ log.Fatal("Unable to init config provider from %q: %v", file, err)
+ }
+ CfgProvider.DisableSaving() // do not allow saving the CfgProvider into file, it will be polluted by the "MustXxx" calls
+}
+
+func MustInstalled() {
+ if !InstallLock {
+ log.Fatal(`Unable to load config file for a installed Forgejo instance, you should either use "--config" to set your config file (app.ini), or run "forgejo web" command to install Forgejo.`)
+ }
+}
+
+func LoadCommonSettings() {
+ if err := loadCommonSettingsFrom(CfgProvider); err != nil {
+ log.Fatal("Unable to load settings from config: %v", err)
+ }
+}
+
+// loadCommonSettingsFrom loads common configurations from a configuration provider.
+func loadCommonSettingsFrom(cfg ConfigProvider) error {
+ // WARNING: don't change the sequence except you know what you are doing.
+ loadRunModeFrom(cfg)
+ loadLogGlobalFrom(cfg)
+ loadServerFrom(cfg)
+ loadSSHFrom(cfg)
+
+ mustCurrentRunUserMatch(cfg) // it depends on the SSH config, only non-builtin SSH server requires this check
+
+ loadOAuth2From(cfg)
+ loadSecurityFrom(cfg)
+ if err := loadAttachmentFrom(cfg); err != nil {
+ return err
+ }
+ if err := loadLFSFrom(cfg); err != nil {
+ return err
+ }
+ loadTimeFrom(cfg)
+ loadRepositoryFrom(cfg)
+ if err := loadAvatarsFrom(cfg); err != nil {
+ return err
+ }
+ if err := loadRepoAvatarFrom(cfg); err != nil {
+ return err
+ }
+ if err := loadPackagesFrom(cfg); err != nil {
+ return err
+ }
+ if err := loadActionsFrom(cfg); err != nil {
+ return err
+ }
+ loadUIFrom(cfg)
+ loadAdminFrom(cfg)
+ loadAPIFrom(cfg)
+ loadBadgesFrom(cfg)
+ loadMetricsFrom(cfg)
+ loadCamoFrom(cfg)
+ loadI18nFrom(cfg)
+ loadGitFrom(cfg)
+ loadMirrorFrom(cfg)
+ loadMarkupFrom(cfg)
+ loadQuotaFrom(cfg)
+ loadOtherFrom(cfg)
+ return nil
+}
+
+func loadRunModeFrom(rootCfg ConfigProvider) {
+ rootSec := rootCfg.Section("")
+ RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
+
+ // The following is a purposefully undocumented option. Please do not run Forgejo as root. It will only cause future headaches.
+ // Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
+ unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")
+ unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || util.OptionalBoolParse(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
+ RunMode = os.Getenv("GITEA_RUN_MODE")
+ if RunMode == "" {
+ RunMode = rootSec.Key("RUN_MODE").MustString("prod")
+ }
+
+ // non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
+ RunMode = strings.ToLower(RunMode)
+ if RunMode != "dev" {
+ RunMode = "prod"
+ }
+ IsProd = RunMode != "dev"
+
+ // check if we run as root
+ if os.Getuid() == 0 {
+ if !unsafeAllowRunAsRoot {
+ // Special thanks to VLC which inspired the wording of this messaging.
+ log.Fatal("Forgejo is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission")
+ }
+ log.Critical("You are running Forgejo using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
+ }
+}
+
+// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
+func HasInstallLock(rootCfg ConfigProvider) bool {
+ return rootCfg.Section("security").Key("INSTALL_LOCK").MustBool(false)
+}
+
+func mustCurrentRunUserMatch(rootCfg ConfigProvider) {
+ // Does not check run user when the "InstallLock" is off.
+ if HasInstallLock(rootCfg) {
+ currentUser, match := IsRunUserMatchCurrentUser(RunUser)
+ if !match {
+ log.Fatal("Expect user '%s' but current user is: %s", RunUser, currentUser)
+ }
+ }
+}
+
+// LoadSettings initializes the settings for normal start up
+func LoadSettings() {
+ initAllLoggers()
+
+ loadDBSetting(CfgProvider)
+ loadServiceFrom(CfgProvider)
+ loadOAuth2ClientFrom(CfgProvider)
+ loadCacheFrom(CfgProvider)
+ loadSessionFrom(CfgProvider)
+ loadCorsFrom(CfgProvider)
+ loadMailsFrom(CfgProvider)
+ loadProxyFrom(CfgProvider)
+ loadWebhookFrom(CfgProvider)
+ loadMigrationsFrom(CfgProvider)
+ loadIndexerFrom(CfgProvider)
+ loadTaskFrom(CfgProvider)
+ LoadQueueSettings()
+ loadProjectFrom(CfgProvider)
+ loadMimeTypeMapFrom(CfgProvider)
+ loadFederationFrom(CfgProvider)
+ loadF3From(CfgProvider)
+}
+
+// LoadSettingsForInstall initializes the settings for install
+func LoadSettingsForInstall() {
+ initAllLoggers()
+
+ loadDBSetting(CfgProvider)
+ loadServiceFrom(CfgProvider)
+ loadMailerFrom(CfgProvider)
+}
diff --git a/modules/setting/setting_test.go b/modules/setting/setting_test.go
new file mode 100644
index 0000000..f77ee65
--- /dev/null
+++ b/modules/setting/setting_test.go
@@ -0,0 +1,32 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMakeAbsoluteAssetURL(t *testing.T) {
+ assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL("https://localhost:1234", "https://localhost:2345"))
+ assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL("https://localhost:1234/", "https://localhost:2345"))
+ assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL("https://localhost:1234/", "https://localhost:2345/"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234", "/foo"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234/", "/foo"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234/", "/foo/"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234/foo", "/foo"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234/foo/", "/foo"))
+ assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL("https://localhost:1234/foo/", "/foo/"))
+ assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL("https://localhost:1234/foo", "/bar"))
+ assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL("https://localhost:1234/foo/", "/bar"))
+ assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL("https://localhost:1234/foo/", "/bar/"))
+}
+
+func TestMakeManifestData(t *testing.T) {
+ jsonBytes := MakeManifestData(`Example App '\"`, "https://example.com", "https://example.com/foo/bar")
+ assert.True(t, json.Valid(jsonBytes))
+}
diff --git a/modules/setting/ssh.go b/modules/setting/ssh.go
new file mode 100644
index 0000000..ea387e5
--- /dev/null
+++ b/modules/setting/ssh.go
@@ -0,0 +1,197 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "text/template"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+
+ gossh "golang.org/x/crypto/ssh"
+)
+
+var SSH = struct {
+ Disabled bool `ini:"DISABLE_SSH"`
+ StartBuiltinServer bool `ini:"START_SSH_SERVER"`
+ BuiltinServerUser string `ini:"BUILTIN_SSH_SERVER_USER"`
+ UseProxyProtocol bool `ini:"SSH_SERVER_USE_PROXY_PROTOCOL"`
+ Domain string `ini:"SSH_DOMAIN"`
+ Port int `ini:"SSH_PORT"`
+ User string `ini:"SSH_USER"`
+ ListenHost string `ini:"SSH_LISTEN_HOST"`
+ ListenPort int `ini:"SSH_LISTEN_PORT"`
+ RootPath string `ini:"SSH_ROOT_PATH"`
+ ServerCiphers []string `ini:"SSH_SERVER_CIPHERS"`
+ ServerKeyExchanges []string `ini:"SSH_SERVER_KEY_EXCHANGES"`
+ ServerMACs []string `ini:"SSH_SERVER_MACS"`
+ ServerHostKeys []string `ini:"SSH_SERVER_HOST_KEYS"`
+ KeyTestPath string `ini:"SSH_KEY_TEST_PATH"`
+ KeygenPath string `ini:"SSH_KEYGEN_PATH"`
+ AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
+ AuthorizedPrincipalsBackup bool `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"`
+ AuthorizedKeysCommandTemplate string `ini:"SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE"`
+ AuthorizedKeysCommandTemplateTemplate *template.Template `ini:"-"`
+ MinimumKeySizeCheck bool `ini:"-"`
+ MinimumKeySizes map[string]int `ini:"-"`
+ CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
+ CreateAuthorizedPrincipalsFile bool `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"`
+ ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"`
+ AuthorizedPrincipalsAllow []string `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"`
+ AuthorizedPrincipalsEnabled bool `ini:"-"`
+ TrustedUserCAKeys []string `ini:"SSH_TRUSTED_USER_CA_KEYS"`
+ TrustedUserCAKeysFile string `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"`
+ TrustedUserCAKeysParsed []gossh.PublicKey `ini:"-"`
+ PerWriteTimeout time.Duration `ini:"SSH_PER_WRITE_TIMEOUT"`
+ PerWritePerKbTimeout time.Duration `ini:"SSH_PER_WRITE_PER_KB_TIMEOUT"`
+}{
+ Disabled: false,
+ StartBuiltinServer: false,
+ Domain: "",
+ Port: 22,
+ ServerCiphers: []string{"chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"},
+ ServerKeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"},
+ ServerMACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"},
+ KeygenPath: "",
+ MinimumKeySizeCheck: true,
+ MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 3071},
+ ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"},
+ AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}",
+ PerWriteTimeout: PerWriteTimeout,
+ PerWritePerKbTimeout: PerWritePerKbTimeout,
+}
+
+func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) {
+ anything := false
+ email := false
+ username := false
+ for _, value := range values {
+ v := strings.ToLower(strings.TrimSpace(value))
+ switch v {
+ case "off":
+ return []string{"off"}, false
+ case "email":
+ email = true
+ case "username":
+ username = true
+ case "anything":
+ anything = true
+ }
+ }
+ if anything {
+ return []string{"anything"}, true
+ }
+
+ authorizedPrincipalsAllow := []string{}
+ if username {
+ authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username")
+ }
+ if email {
+ authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email")
+ }
+
+ return authorizedPrincipalsAllow, true
+}
+
+func loadSSHFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("server")
+ if len(SSH.Domain) == 0 {
+ SSH.Domain = Domain
+ }
+
+ homeDir, err := util.HomeDir()
+ if err != nil {
+ log.Fatal("Failed to get home directory: %v", err)
+ }
+ homeDir = strings.ReplaceAll(homeDir, "\\", "/")
+
+ SSH.RootPath = path.Join(homeDir, ".ssh")
+ serverCiphers := sec.Key("SSH_SERVER_CIPHERS").Strings(",")
+ if len(serverCiphers) > 0 {
+ SSH.ServerCiphers = serverCiphers
+ }
+ serverKeyExchanges := sec.Key("SSH_SERVER_KEY_EXCHANGES").Strings(",")
+ if len(serverKeyExchanges) > 0 {
+ SSH.ServerKeyExchanges = serverKeyExchanges
+ }
+ serverMACs := sec.Key("SSH_SERVER_MACS").Strings(",")
+ if len(serverMACs) > 0 {
+ SSH.ServerMACs = serverMACs
+ }
+ SSH.KeyTestPath = os.TempDir()
+ if err = sec.MapTo(&SSH); err != nil {
+ log.Fatal("Failed to map SSH settings: %v", err)
+ }
+ for i, key := range SSH.ServerHostKeys {
+ if !filepath.IsAbs(key) {
+ SSH.ServerHostKeys[i] = filepath.Join(AppDataPath, key)
+ }
+ }
+
+ SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").String()
+ SSH.Port = sec.Key("SSH_PORT").MustInt(22)
+ SSH.ListenPort = sec.Key("SSH_LISTEN_PORT").MustInt(SSH.Port)
+ SSH.UseProxyProtocol = sec.Key("SSH_SERVER_USE_PROXY_PROTOCOL").MustBool(false)
+
+ // When disable SSH, start builtin server value is ignored.
+ if SSH.Disabled {
+ SSH.StartBuiltinServer = false
+ }
+
+ SSH.TrustedUserCAKeysFile = sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem"))
+
+ for _, caKey := range SSH.TrustedUserCAKeys {
+ pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey))
+ if err != nil {
+ log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err)
+ }
+
+ SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey)
+ }
+ if len(SSH.TrustedUserCAKeys) > 0 {
+ // Set the default as email,username otherwise we can leave it empty
+ sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email")
+ } else {
+ sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off")
+ }
+
+ SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(","))
+
+ SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck)
+ minimumKeySizes := rootCfg.Section("ssh.minimum_key_sizes").Keys()
+ for _, key := range minimumKeySizes {
+ if key.MustInt() != -1 {
+ SSH.MinimumKeySizes[strings.ToLower(key.Name())] = key.MustInt()
+ } else {
+ delete(SSH.MinimumKeySizes, strings.ToLower(key.Name()))
+ }
+ }
+
+ SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(false)
+ SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true)
+
+ SSH.AuthorizedPrincipalsBackup = false
+ SSH.CreateAuthorizedPrincipalsFile = false
+ if SSH.AuthorizedPrincipalsEnabled {
+ SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true)
+ SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true)
+ }
+
+ SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false)
+ SSH.AuthorizedKeysCommandTemplate = sec.Key("SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE").MustString(SSH.AuthorizedKeysCommandTemplate)
+
+ SSH.AuthorizedKeysCommandTemplateTemplate = template.Must(template.New("").Parse(SSH.AuthorizedKeysCommandTemplate))
+
+ SSH.PerWriteTimeout = sec.Key("SSH_PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout)
+ SSH.PerWritePerKbTimeout = sec.Key("SSH_PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout)
+
+ // ensure parseRunModeSetting has been executed before this
+ SSH.BuiltinServerUser = rootCfg.Section("server").Key("BUILTIN_SSH_SERVER_USER").MustString(RunUser)
+ SSH.User = rootCfg.Section("server").Key("SSH_USER").MustString(SSH.BuiltinServerUser)
+}
diff --git a/modules/setting/storage.go b/modules/setting/storage.go
new file mode 100644
index 0000000..8ee5c0f
--- /dev/null
+++ b/modules/setting/storage.go
@@ -0,0 +1,275 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// StorageType is a type of Storage
+type StorageType string
+
+const (
+ // LocalStorageType is the type descriptor for local storage
+ LocalStorageType StorageType = "local"
+ // MinioStorageType is the type descriptor for minio storage
+ MinioStorageType StorageType = "minio"
+)
+
+var storageTypes = []StorageType{
+ LocalStorageType,
+ MinioStorageType,
+}
+
+// IsValidStorageType returns true if the given storage type is valid
+func IsValidStorageType(storageType StorageType) bool {
+ for _, t := range storageTypes {
+ if t == storageType {
+ return true
+ }
+ }
+ return false
+}
+
+// MinioStorageConfig represents the configuration for a minio storage
+type MinioStorageConfig struct {
+ Endpoint string `ini:"MINIO_ENDPOINT" json:",omitempty"`
+ AccessKeyID string `ini:"MINIO_ACCESS_KEY_ID" json:",omitempty"`
+ SecretAccessKey string `ini:"MINIO_SECRET_ACCESS_KEY" json:",omitempty"`
+ Bucket string `ini:"MINIO_BUCKET" json:",omitempty"`
+ BucketLookup string `ini:"MINIO_BUCKET_LOOKUP" json:",omitempty"`
+ Location string `ini:"MINIO_LOCATION" json:",omitempty"`
+ BasePath string `ini:"MINIO_BASE_PATH" json:",omitempty"`
+ UseSSL bool `ini:"MINIO_USE_SSL"`
+ InsecureSkipVerify bool `ini:"MINIO_INSECURE_SKIP_VERIFY"`
+ ChecksumAlgorithm string `ini:"MINIO_CHECKSUM_ALGORITHM" json:",omitempty"`
+ ServeDirect bool `ini:"SERVE_DIRECT"`
+}
+
+// Storage represents configuration of storages
+type Storage struct {
+ Type StorageType // local or minio
+ Path string `json:",omitempty"` // for local type
+ TemporaryPath string `json:",omitempty"`
+ MinioConfig MinioStorageConfig // for minio type
+}
+
+func (storage *Storage) ToShadowCopy() Storage {
+ shadowStorage := *storage
+ if shadowStorage.MinioConfig.AccessKeyID != "" {
+ shadowStorage.MinioConfig.AccessKeyID = "******"
+ }
+ if shadowStorage.MinioConfig.SecretAccessKey != "" {
+ shadowStorage.MinioConfig.SecretAccessKey = "******"
+ }
+ return shadowStorage
+}
+
+const storageSectionName = "storage"
+
+func getDefaultStorageSection(rootCfg ConfigProvider) ConfigSection {
+ storageSec := rootCfg.Section(storageSectionName)
+ // Global Defaults
+ storageSec.Key("STORAGE_TYPE").MustString("local")
+ storageSec.Key("MINIO_ENDPOINT").MustString("localhost:9000")
+ storageSec.Key("MINIO_ACCESS_KEY_ID").MustString("")
+ storageSec.Key("MINIO_SECRET_ACCESS_KEY").MustString("")
+ storageSec.Key("MINIO_BUCKET").MustString("gitea")
+ storageSec.Key("MINIO_BUCKET_LOOKUP").MustString("auto")
+ storageSec.Key("MINIO_LOCATION").MustString("us-east-1")
+ storageSec.Key("MINIO_USE_SSL").MustBool(false)
+ storageSec.Key("MINIO_INSECURE_SKIP_VERIFY").MustBool(false)
+ storageSec.Key("MINIO_CHECKSUM_ALGORITHM").MustString("default")
+ return storageSec
+}
+
+// getStorage will find target section and extra special section first and then read override
+// items from extra section
+func getStorage(rootCfg ConfigProvider, name, typ string, sec ConfigSection) (*Storage, error) {
+ if name == "" {
+ return nil, errors.New("no name for storage")
+ }
+
+ targetSec, tp, err := getStorageTargetSection(rootCfg, name, typ, sec)
+ if err != nil {
+ return nil, err
+ }
+
+ overrideSec := getStorageOverrideSection(rootCfg, sec, tp, name)
+
+ targetType := targetSec.Key("STORAGE_TYPE").String()
+ switch targetType {
+ case string(LocalStorageType):
+ return getStorageForLocal(targetSec, overrideSec, tp, name)
+ case string(MinioStorageType):
+ return getStorageForMinio(targetSec, overrideSec, tp, name)
+ default:
+ return nil, fmt.Errorf("unsupported storage type %q", targetType)
+ }
+}
+
+type targetSecType int
+
+const (
+ targetSecIsTyp targetSecType = iota // target section is [storage.type] which the type from parameter
+ targetSecIsStorage // target section is [storage]
+ targetSecIsDefault // target section is the default value
+ targetSecIsStorageWithName // target section is [storage.name]
+ targetSecIsSec // target section is from the name seciont [name]
+)
+
+func getStorageSectionByType(rootCfg ConfigProvider, typ string) (ConfigSection, targetSecType, error) { //nolint:unparam
+ targetSec, err := rootCfg.GetSection(storageSectionName + "." + typ)
+ if err != nil {
+ if !IsValidStorageType(StorageType(typ)) {
+ return nil, 0, fmt.Errorf("get section via storage type %q failed: %v", typ, err)
+ }
+ // if typ is a valid storage type, but there is no [storage.local] or [storage.minio] section
+ // it's not an error
+ return nil, 0, nil
+ }
+
+ targetType := targetSec.Key("STORAGE_TYPE").String()
+ if targetType == "" {
+ if !IsValidStorageType(StorageType(typ)) {
+ return nil, 0, fmt.Errorf("unknow storage type %q", typ)
+ }
+ targetSec.Key("STORAGE_TYPE").SetValue(typ)
+ } else if !IsValidStorageType(StorageType(targetType)) {
+ return nil, 0, fmt.Errorf("unknow storage type %q for section storage.%v", targetType, typ)
+ }
+
+ return targetSec, targetSecIsTyp, nil
+}
+
+func getStorageTargetSection(rootCfg ConfigProvider, name, typ string, sec ConfigSection) (ConfigSection, targetSecType, error) {
+ // check typ first
+ if typ == "" {
+ if sec != nil { // check sec's type secondly
+ typ = sec.Key("STORAGE_TYPE").String()
+ if IsValidStorageType(StorageType(typ)) {
+ if targetSec, _ := rootCfg.GetSection(storageSectionName + "." + typ); targetSec == nil {
+ return sec, targetSecIsSec, nil
+ }
+ }
+ }
+ }
+
+ if typ != "" {
+ targetSec, tp, err := getStorageSectionByType(rootCfg, typ)
+ if targetSec != nil || err != nil {
+ return targetSec, tp, err
+ }
+ }
+
+ // check stoarge name thirdly
+ targetSec, _ := rootCfg.GetSection(storageSectionName + "." + name)
+ if targetSec != nil {
+ targetType := targetSec.Key("STORAGE_TYPE").String()
+ switch {
+ case targetType == "":
+ if targetSec.Key("PATH").String() == "" { // both storage type and path are empty, use default
+ return getDefaultStorageSection(rootCfg), targetSecIsDefault, nil
+ }
+
+ targetSec.Key("STORAGE_TYPE").SetValue("local")
+ default:
+ targetSec, tp, err := getStorageSectionByType(rootCfg, targetType)
+ if targetSec != nil || err != nil {
+ return targetSec, tp, err
+ }
+ }
+
+ return targetSec, targetSecIsStorageWithName, nil
+ }
+
+ return getDefaultStorageSection(rootCfg), targetSecIsDefault, nil
+}
+
+// getStorageOverrideSection override section will be read SERVE_DIRECT, PATH, MINIO_BASE_PATH, MINIO_BUCKET to override the targetsec when possible
+func getStorageOverrideSection(rootConfig ConfigProvider, sec ConfigSection, targetSecType targetSecType, name string) ConfigSection {
+ if targetSecType == targetSecIsSec {
+ return nil
+ }
+
+ if sec != nil {
+ return sec
+ }
+
+ if targetSecType != targetSecIsStorageWithName {
+ nameSec, _ := rootConfig.GetSection(storageSectionName + "." + name)
+ return nameSec
+ }
+ return nil
+}
+
+func getStorageForLocal(targetSec, overrideSec ConfigSection, tp targetSecType, name string) (*Storage, error) {
+ storage := Storage{
+ Type: StorageType(targetSec.Key("STORAGE_TYPE").String()),
+ }
+
+ targetPath := ConfigSectionKeyString(targetSec, "PATH", "")
+ var fallbackPath string
+ if targetPath == "" { // no path
+ fallbackPath = filepath.Join(AppDataPath, name)
+ } else {
+ if tp == targetSecIsStorage || tp == targetSecIsDefault {
+ fallbackPath = filepath.Join(targetPath, name)
+ } else {
+ fallbackPath = targetPath
+ }
+ if !filepath.IsAbs(fallbackPath) {
+ fallbackPath = filepath.Join(AppDataPath, fallbackPath)
+ }
+ }
+
+ if overrideSec == nil { // no override section
+ storage.Path = fallbackPath
+ } else {
+ storage.Path = ConfigSectionKeyString(overrideSec, "PATH", "")
+ if storage.Path == "" { // overrideSec has no path
+ storage.Path = fallbackPath
+ } else if !filepath.IsAbs(storage.Path) {
+ if targetPath == "" {
+ storage.Path = filepath.Join(AppDataPath, storage.Path)
+ } else {
+ storage.Path = filepath.Join(targetPath, storage.Path)
+ }
+ }
+ }
+
+ return &storage, nil
+}
+
+func getStorageForMinio(targetSec, overrideSec ConfigSection, tp targetSecType, name string) (*Storage, error) {
+ var storage Storage
+ storage.Type = StorageType(targetSec.Key("STORAGE_TYPE").String())
+ if err := targetSec.MapTo(&storage.MinioConfig); err != nil {
+ return nil, fmt.Errorf("map minio config failed: %v", err)
+ }
+
+ var defaultPath string
+ if storage.MinioConfig.BasePath != "" {
+ if tp == targetSecIsStorage || tp == targetSecIsDefault {
+ defaultPath = strings.TrimSuffix(storage.MinioConfig.BasePath, "/") + "/" + name + "/"
+ } else {
+ defaultPath = storage.MinioConfig.BasePath
+ }
+ }
+ if defaultPath == "" {
+ defaultPath = name + "/"
+ }
+
+ if overrideSec != nil {
+ storage.MinioConfig.ServeDirect = ConfigSectionKeyBool(overrideSec, "SERVE_DIRECT", storage.MinioConfig.ServeDirect)
+ storage.MinioConfig.BasePath = ConfigSectionKeyString(overrideSec, "MINIO_BASE_PATH", defaultPath)
+ storage.MinioConfig.Bucket = ConfigSectionKeyString(overrideSec, "MINIO_BUCKET", storage.MinioConfig.Bucket)
+ } else {
+ storage.MinioConfig.BasePath = defaultPath
+ }
+ return &storage, nil
+}
diff --git a/modules/setting/storage_test.go b/modules/setting/storage_test.go
new file mode 100644
index 0000000..2716079
--- /dev/null
+++ b/modules/setting/storage_test.go
@@ -0,0 +1,468 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getStorageMultipleName(t *testing.T) {
+ iniStr := `
+[lfs]
+MINIO_BUCKET = gitea-lfs
+
+[attachment]
+MINIO_BUCKET = gitea-attachment
+
+[storage]
+STORAGE_TYPE = minio
+MINIO_BUCKET = gitea-storage
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+ assert.EqualValues(t, "gitea-attachment", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadLFSFrom(cfg))
+ assert.EqualValues(t, "gitea-lfs", LFS.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadAvatarsFrom(cfg))
+ assert.EqualValues(t, "gitea-storage", Avatar.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "avatars/", Avatar.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageUseOtherNameAsType(t *testing.T) {
+ iniStr := `
+[attachment]
+STORAGE_TYPE = lfs
+
+[storage.lfs]
+STORAGE_TYPE = minio
+MINIO_BUCKET = gitea-storage
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadAttachmentFrom(cfg))
+ assert.EqualValues(t, "gitea-storage", Attachment.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "attachments/", Attachment.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadLFSFrom(cfg))
+ assert.EqualValues(t, "gitea-storage", LFS.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "lfs/", LFS.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageInheritStorageType(t *testing.T) {
+ iniStr := `
+[storage]
+STORAGE_TYPE = minio
+`
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+
+ require.NoError(t, loadPackagesFrom(cfg))
+ assert.EqualValues(t, "minio", Packages.Storage.Type)
+ assert.EqualValues(t, "gitea", Packages.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "packages/", Packages.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ assert.EqualValues(t, "minio", RepoArchive.Storage.Type)
+ assert.EqualValues(t, "gitea", RepoArchive.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadActionsFrom(cfg))
+ assert.EqualValues(t, "minio", Actions.LogStorage.Type)
+ assert.EqualValues(t, "gitea", Actions.LogStorage.MinioConfig.Bucket)
+ assert.EqualValues(t, "actions_log/", Actions.LogStorage.MinioConfig.BasePath)
+
+ assert.EqualValues(t, "minio", Actions.ArtifactStorage.Type)
+ assert.EqualValues(t, "gitea", Actions.ArtifactStorage.MinioConfig.Bucket)
+ assert.EqualValues(t, "actions_artifacts/", Actions.ArtifactStorage.MinioConfig.BasePath)
+
+ require.NoError(t, loadAvatarsFrom(cfg))
+ assert.EqualValues(t, "minio", Avatar.Storage.Type)
+ assert.EqualValues(t, "gitea", Avatar.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "avatars/", Avatar.Storage.MinioConfig.BasePath)
+
+ require.NoError(t, loadRepoAvatarFrom(cfg))
+ assert.EqualValues(t, "minio", RepoAvatar.Storage.Type)
+ assert.EqualValues(t, "gitea", RepoAvatar.Storage.MinioConfig.Bucket)
+ assert.EqualValues(t, "repo-avatars/", RepoAvatar.Storage.MinioConfig.BasePath)
+}
+
+type testLocalStoragePathCase struct {
+ loader func(rootCfg ConfigProvider) error
+ storagePtr **Storage
+ expectedPath string
+}
+
+func testLocalStoragePath(t *testing.T, appDataPath, iniStr string, cases []testLocalStoragePathCase) {
+ cfg, err := NewConfigProviderFromData(iniStr)
+ require.NoError(t, err)
+ AppDataPath = appDataPath
+ for _, c := range cases {
+ require.NoError(t, c.loader(cfg))
+ storage := *c.storagePtr
+
+ assert.EqualValues(t, "local", storage.Type)
+ assert.True(t, filepath.IsAbs(storage.Path))
+ assert.EqualValues(t, filepath.Clean(c.expectedPath), filepath.Clean(storage.Path))
+ }
+}
+
+func Test_getStorageInheritStorageTypeLocal(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/repo-archive"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPath(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+PATH = /data/gitea
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/data/gitea/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/data/gitea/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/data/gitea/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/data/gitea/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/repo-archive"},
+ {loadActionsFrom, &Actions.LogStorage, "/data/gitea/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/data/gitea/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/data/gitea/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalRelativePath(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+PATH = storages
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/storages/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/storages/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/storages/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/storages/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/storages/repo-archive"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/storages/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/storages/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/storages/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+PATH = /data/gitea
+
+[repo-archive]
+PATH = /data/gitea/the-archives-dir
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/data/gitea/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/data/gitea/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/data/gitea/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/data/gitea/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/the-archives-dir"},
+ {loadActionsFrom, &Actions.LogStorage, "/data/gitea/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/data/gitea/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/data/gitea/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverrideEmpty(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+PATH = /data/gitea
+
+[repo-archive]
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/data/gitea/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/data/gitea/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/data/gitea/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/data/gitea/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/repo-archive"},
+ {loadActionsFrom, &Actions.LogStorage, "/data/gitea/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/data/gitea/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/data/gitea/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalRelativePathOverride(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage]
+STORAGE_TYPE = local
+PATH = /data/gitea
+
+[repo-archive]
+PATH = the-archives-dir
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/data/gitea/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/data/gitea/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/data/gitea/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/data/gitea/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/the-archives-dir"},
+ {loadActionsFrom, &Actions.LogStorage, "/data/gitea/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/data/gitea/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/data/gitea/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride3(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+STORAGE_TYPE = local
+PATH = /data/gitea/archives
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/archives"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride3_5(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+STORAGE_TYPE = local
+PATH = a-relative-path
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/a-relative-path"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride4(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+STORAGE_TYPE = local
+PATH = /data/gitea/archives
+
+[repo-archive]
+PATH = /tmp/gitea/archives
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/tmp/gitea/archives"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride5(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+STORAGE_TYPE = local
+PATH = /data/gitea/archives
+
+[repo-archive]
+`, []testLocalStoragePathCase{
+ {loadAttachmentFrom, &Attachment.Storage, "/appdata/attachments"},
+ {loadLFSFrom, &LFS.Storage, "/appdata/lfs"},
+ {loadActionsFrom, &Actions.ArtifactStorage, "/appdata/actions_artifacts"},
+ {loadPackagesFrom, &Packages.Storage, "/appdata/packages"},
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/data/gitea/archives"},
+ {loadActionsFrom, &Actions.LogStorage, "/appdata/actions_log"},
+ {loadAvatarsFrom, &Avatar.Storage, "/appdata/avatars"},
+ {loadRepoAvatarFrom, &RepoAvatar.Storage, "/appdata/repo-avatars"},
+ })
+}
+
+func Test_getStorageInheritStorageTypeLocalPathOverride72(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[repo-archive]
+STORAGE_TYPE = local
+PATH = archives
+`, []testLocalStoragePathCase{
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/archives"},
+ })
+}
+
+func Test_getStorageConfiguration20(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[repo-archive]
+STORAGE_TYPE = my_storage
+PATH = archives
+`)
+ require.NoError(t, err)
+
+ require.Error(t, loadRepoArchiveFrom(cfg))
+}
+
+func Test_getStorageConfiguration21(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+`, []testLocalStoragePathCase{
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/repo-archive"},
+ })
+}
+
+func Test_getStorageConfiguration22(t *testing.T) {
+ testLocalStoragePath(t, "/appdata", `
+[storage.repo-archive]
+PATH = archives
+`, []testLocalStoragePathCase{
+ {loadRepoArchiveFrom, &RepoArchive.Storage, "/appdata/archives"},
+ })
+}
+
+func Test_getStorageConfiguration23(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[repo-archive]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+`)
+ require.NoError(t, err)
+
+ _, err = getStorage(cfg, "", "", nil)
+ require.Error(t, err)
+
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ cp := RepoArchive.Storage.ToShadowCopy()
+ assert.EqualValues(t, "******", cp.MinioConfig.AccessKeyID)
+ assert.EqualValues(t, "******", cp.MinioConfig.SecretAccessKey)
+}
+
+func Test_getStorageConfiguration24(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[repo-archive]
+STORAGE_TYPE = my_archive
+
+[storage.my_archive]
+; unsupported, storage type should be defined explicitly
+PATH = archives
+`)
+ require.NoError(t, err)
+ require.Error(t, loadRepoArchiveFrom(cfg))
+}
+
+func Test_getStorageConfiguration25(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[repo-archive]
+STORAGE_TYPE = my_archive
+
+[storage.my_archive]
+; unsupported, storage type should be known type
+STORAGE_TYPE = unknown // should be local or minio
+PATH = archives
+`)
+ require.NoError(t, err)
+ require.Error(t, loadRepoArchiveFrom(cfg))
+}
+
+func Test_getStorageConfiguration26(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[repo-archive]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+; wrong configuration
+MINIO_USE_SSL = abc
+`)
+ require.NoError(t, err)
+ // require.Error(t, loadRepoArchiveFrom(cfg))
+ // FIXME: this should return error but now ini package's MapTo() doesn't check type
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+}
+
+func Test_getStorageConfiguration27(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[storage.repo-archive]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+MINIO_USE_SSL = true
+`)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ assert.EqualValues(t, "my_access_key", RepoArchive.Storage.MinioConfig.AccessKeyID)
+ assert.EqualValues(t, "my_secret_key", RepoArchive.Storage.MinioConfig.SecretAccessKey)
+ assert.True(t, RepoArchive.Storage.MinioConfig.UseSSL)
+ assert.EqualValues(t, "repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+}
+
+func Test_getStorageConfiguration28(t *testing.T) {
+ cfg, err := NewConfigProviderFromData(`
+[storage]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+MINIO_USE_SSL = true
+MINIO_BASE_PATH = /prefix
+`)
+ require.NoError(t, err)
+ require.NoError(t, loadRepoArchiveFrom(cfg))
+ assert.EqualValues(t, "my_access_key", RepoArchive.Storage.MinioConfig.AccessKeyID)
+ assert.EqualValues(t, "my_secret_key", RepoArchive.Storage.MinioConfig.SecretAccessKey)
+ assert.True(t, RepoArchive.Storage.MinioConfig.UseSSL)
+ assert.EqualValues(t, "/prefix/repo-archive/", RepoArchive.Storage.MinioConfig.BasePath)
+
+ cfg, err = NewConfigProviderFromData(`
+[storage]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+MINIO_USE_SSL = true
+MINIO_BASE_PATH = /prefix
+
+[lfs]
+MINIO_BASE_PATH = /lfs
+`)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+ assert.EqualValues(t, "my_access_key", LFS.Storage.MinioConfig.AccessKeyID)
+ assert.EqualValues(t, "my_secret_key", LFS.Storage.MinioConfig.SecretAccessKey)
+ assert.True(t, true, LFS.Storage.MinioConfig.UseSSL)
+ assert.EqualValues(t, "/lfs", LFS.Storage.MinioConfig.BasePath)
+
+ cfg, err = NewConfigProviderFromData(`
+[storage]
+STORAGE_TYPE = minio
+MINIO_ACCESS_KEY_ID = my_access_key
+MINIO_SECRET_ACCESS_KEY = my_secret_key
+MINIO_USE_SSL = true
+MINIO_BASE_PATH = /prefix
+
+[storage.lfs]
+MINIO_BASE_PATH = /lfs
+`)
+ require.NoError(t, err)
+ require.NoError(t, loadLFSFrom(cfg))
+ assert.EqualValues(t, "my_access_key", LFS.Storage.MinioConfig.AccessKeyID)
+ assert.EqualValues(t, "my_secret_key", LFS.Storage.MinioConfig.SecretAccessKey)
+ assert.True(t, LFS.Storage.MinioConfig.UseSSL)
+ assert.EqualValues(t, "/lfs", LFS.Storage.MinioConfig.BasePath)
+}
diff --git a/modules/setting/task.go b/modules/setting/task.go
new file mode 100644
index 0000000..f75b4f1
--- /dev/null
+++ b/modules/setting/task.go
@@ -0,0 +1,26 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// DEPRECATED should not be removed because users maybe upgrade from lower version to the latest version
+// if these are removed, the warning will not be shown
+// - will need to set default for [queue.task] LENGTH to 1000 though
+func loadTaskFrom(rootCfg ConfigProvider) {
+ taskSec := rootCfg.Section("task")
+ queueTaskSec := rootCfg.Section("queue.task")
+
+ deprecatedSetting(rootCfg, "task", "QUEUE_TYPE", "queue.task", "TYPE", "v1.19.0")
+ deprecatedSetting(rootCfg, "task", "QUEUE_CONN_STR", "queue.task", "CONN_STR", "v1.19.0")
+ deprecatedSetting(rootCfg, "task", "QUEUE_LENGTH", "queue.task", "LENGTH", "v1.19.0")
+
+ switch taskSec.Key("QUEUE_TYPE").MustString("channel") {
+ case "channel":
+ queueTaskSec.Key("TYPE").MustString("persistable-channel")
+ queueTaskSec.Key("CONN_STR").MustString(taskSec.Key("QUEUE_CONN_STR").MustString(""))
+ case "redis":
+ queueTaskSec.Key("TYPE").MustString("redis")
+ queueTaskSec.Key("CONN_STR").MustString(taskSec.Key("QUEUE_CONN_STR").MustString("addrs=127.0.0.1:6379 db=0"))
+ }
+ queueTaskSec.Key("LENGTH").MustInt(taskSec.Key("QUEUE_LENGTH").MustInt(1000))
+}
diff --git a/modules/setting/time.go b/modules/setting/time.go
new file mode 100644
index 0000000..39acba1
--- /dev/null
+++ b/modules/setting/time.go
@@ -0,0 +1,28 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// DefaultUILocation is the location on the UI, so that we can display the time on UI.
+var DefaultUILocation = time.Local
+
+func loadTimeFrom(rootCfg ConfigProvider) {
+ zone := rootCfg.Section("time").Key("DEFAULT_UI_LOCATION").String()
+ if zone != "" {
+ var err error
+ DefaultUILocation, err = time.LoadLocation(zone)
+ if err != nil {
+ log.Fatal("Load time zone failed: %v", err)
+ }
+ log.Info("Default UI Location is %v", zone)
+ }
+ if DefaultUILocation == nil {
+ DefaultUILocation = time.Local
+ }
+}
diff --git a/modules/setting/ui.go b/modules/setting/ui.go
new file mode 100644
index 0000000..056d670
--- /dev/null
+++ b/modules/setting/ui.go
@@ -0,0 +1,169 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// UI settings
+var UI = struct {
+ ExplorePagingNum int
+ SitemapPagingNum int
+ IssuePagingNum int
+ RepoSearchPagingNum int
+ MembersPagingNum int
+ FeedMaxCommitNum int
+ FeedPagingNum int
+ PackagesPagingNum int
+ GraphMaxCommitNum int
+ CodeCommentLines int
+ ReactionMaxUserNum int
+ MaxDisplayFileSize int64
+ ShowUserEmail bool
+ DefaultShowFullName bool
+ DefaultTheme string
+ Themes []string
+ Reactions []string
+ ReactionsLookup container.Set[string] `ini:"-"`
+ CustomEmojis []string
+ CustomEmojisMap map[string]string `ini:"-"`
+ SearchRepoDescription bool
+ OnlyShowRelevantRepos bool
+ ExploreDefaultSort string `ini:"EXPLORE_PAGING_DEFAULT_SORT"`
+ PreferredTimestampTense string
+
+ AmbiguousUnicodeDetection bool
+ SkipEscapeContexts []string
+
+ Notification struct {
+ MinTimeout time.Duration
+ TimeoutStep time.Duration
+ MaxTimeout time.Duration
+ EventSourceUpdateTime time.Duration
+ } `ini:"ui.notification"`
+
+ SVG struct {
+ Enabled bool `ini:"ENABLE_RENDER"`
+ } `ini:"ui.svg"`
+
+ CSV struct {
+ MaxFileSize int64
+ MaxRows int
+ } `ini:"ui.csv"`
+
+ Admin struct {
+ UserPagingNum int
+ RepoPagingNum int
+ NoticePagingNum int
+ OrgPagingNum int
+ } `ini:"ui.admin"`
+ User struct {
+ RepoPagingNum int
+ } `ini:"ui.user"`
+ Meta struct {
+ Author string
+ Description string
+ Keywords string
+ } `ini:"ui.meta"`
+}{
+ ExplorePagingNum: 20,
+ SitemapPagingNum: 20,
+ IssuePagingNum: 20,
+ RepoSearchPagingNum: 20,
+ MembersPagingNum: 20,
+ FeedMaxCommitNum: 5,
+ FeedPagingNum: 20,
+ PackagesPagingNum: 20,
+ GraphMaxCommitNum: 100,
+ CodeCommentLines: 4,
+ ReactionMaxUserNum: 10,
+ MaxDisplayFileSize: 8388608,
+ DefaultTheme: `forgejo-auto`,
+ Themes: []string{`forgejo-auto`, `forgejo-light`, `forgejo-dark`, `gitea-auto`, `gitea-light`, `gitea-dark`, `forgejo-auto-deuteranopia-protanopia`, `forgejo-light-deuteranopia-protanopia`, `forgejo-dark-deuteranopia-protanopia`, `forgejo-auto-tritanopia`, `forgejo-light-tritanopia`, `forgejo-dark-tritanopia`},
+ Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
+ CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`, `forgejo`},
+ CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:", "forgejo": ":forgejo:"},
+ PreferredTimestampTense: "mixed",
+
+ AmbiguousUnicodeDetection: true,
+ SkipEscapeContexts: []string{},
+
+ Notification: struct {
+ MinTimeout time.Duration
+ TimeoutStep time.Duration
+ MaxTimeout time.Duration
+ EventSourceUpdateTime time.Duration
+ }{
+ MinTimeout: 10 * time.Second,
+ TimeoutStep: 10 * time.Second,
+ MaxTimeout: 60 * time.Second,
+ EventSourceUpdateTime: 10 * time.Second,
+ },
+ SVG: struct {
+ Enabled bool `ini:"ENABLE_RENDER"`
+ }{
+ Enabled: true,
+ },
+ CSV: struct {
+ MaxFileSize int64
+ MaxRows int
+ }{
+ MaxFileSize: 524288,
+ MaxRows: 2500,
+ },
+ Admin: struct {
+ UserPagingNum int
+ RepoPagingNum int
+ NoticePagingNum int
+ OrgPagingNum int
+ }{
+ UserPagingNum: 50,
+ RepoPagingNum: 50,
+ NoticePagingNum: 25,
+ OrgPagingNum: 50,
+ },
+ User: struct {
+ RepoPagingNum int
+ }{
+ RepoPagingNum: 15,
+ },
+ Meta: struct {
+ Author string
+ Description string
+ Keywords string
+ }{
+ Author: "Forgejo – Beyond coding. We forge.",
+ Description: "Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.",
+ Keywords: "git,forge,forgejo",
+ },
+}
+
+func loadUIFrom(rootCfg ConfigProvider) {
+ mustMapSetting(rootCfg, "ui", &UI)
+ sec := rootCfg.Section("ui")
+ UI.ShowUserEmail = sec.Key("SHOW_USER_EMAIL").MustBool(true)
+ UI.DefaultShowFullName = sec.Key("DEFAULT_SHOW_FULL_NAME").MustBool(false)
+ UI.SearchRepoDescription = sec.Key("SEARCH_REPO_DESCRIPTION").MustBool(true)
+
+ if UI.PreferredTimestampTense != "mixed" && UI.PreferredTimestampTense != "absolute" {
+ log.Fatal("ui.PREFERRED_TIMESTAMP_TENSE must be either 'mixed' or 'absolute'")
+ }
+
+ // OnlyShowRelevantRepos=false is important for many private/enterprise instances,
+ // because many private repositories do not have "description/topic", users just want to search by their names.
+ UI.OnlyShowRelevantRepos = sec.Key("ONLY_SHOW_RELEVANT_REPOS").MustBool(false)
+
+ UI.ReactionsLookup = make(container.Set[string])
+ for _, reaction := range UI.Reactions {
+ UI.ReactionsLookup.Add(reaction)
+ }
+ UI.CustomEmojisMap = make(map[string]string)
+ for _, emoji := range UI.CustomEmojis {
+ UI.CustomEmojisMap[emoji] = ":" + emoji + ":"
+ }
+}
diff --git a/modules/setting/webhook.go b/modules/setting/webhook.go
new file mode 100644
index 0000000..7b1ab4d
--- /dev/null
+++ b/modules/setting/webhook.go
@@ -0,0 +1,48 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/url"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Webhook settings
+var Webhook = struct {
+ QueueLength int
+ DeliverTimeout int
+ SkipTLSVerify bool
+ AllowedHostList string
+ PagingNum int
+ ProxyURL string
+ ProxyURLFixed *url.URL
+ ProxyHosts []string
+}{
+ QueueLength: 1000,
+ DeliverTimeout: 5,
+ SkipTLSVerify: false,
+ PagingNum: 10,
+ ProxyURL: "",
+ ProxyHosts: []string{},
+}
+
+func loadWebhookFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("webhook")
+ Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
+ Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
+ Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
+ Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
+ Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
+ Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
+ if Webhook.ProxyURL != "" {
+ var err error
+ Webhook.ProxyURLFixed, err = url.Parse(Webhook.ProxyURL)
+ if err != nil {
+ log.Error("Webhook PROXY_URL is not valid")
+ Webhook.ProxyURL = ""
+ }
+ }
+ Webhook.ProxyHosts = sec.Key("PROXY_HOSTS").Strings(",")
+}
diff --git a/modules/sitemap/sitemap.go b/modules/sitemap/sitemap.go
new file mode 100644
index 0000000..280ca1d
--- /dev/null
+++ b/modules/sitemap/sitemap.go
@@ -0,0 +1,82 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sitemap
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "time"
+)
+
+const (
+ sitemapFileLimit = 50 * 1024 * 1024 // the maximum size of a sitemap file
+ urlsLimit = 50000
+
+ schemaURL = "http://www.sitemaps.org/schemas/sitemap/0.9"
+ urlsetName = "urlset"
+ sitemapindexName = "sitemapindex"
+)
+
+// URL represents a single sitemap entry
+type URL struct {
+ URL string `xml:"loc"`
+ LastMod *time.Time `xml:"lastmod,omitempty"`
+}
+
+// Sitemap represents a sitemap
+type Sitemap struct {
+ XMLName xml.Name
+ Namespace string `xml:"xmlns,attr"`
+
+ URLs []URL `xml:"url"`
+ Sitemaps []URL `xml:"sitemap"`
+}
+
+// NewSitemap creates a sitemap
+func NewSitemap() *Sitemap {
+ return &Sitemap{
+ XMLName: xml.Name{Local: urlsetName},
+ Namespace: schemaURL,
+ }
+}
+
+// NewSitemapIndex creates a sitemap index.
+func NewSitemapIndex() *Sitemap {
+ return &Sitemap{
+ XMLName: xml.Name{Local: sitemapindexName},
+ Namespace: schemaURL,
+ }
+}
+
+// Add adds a URL to the sitemap
+func (s *Sitemap) Add(u URL) {
+ if s.XMLName.Local == sitemapindexName {
+ s.Sitemaps = append(s.Sitemaps, u)
+ } else {
+ s.URLs = append(s.URLs, u)
+ }
+}
+
+// WriteTo writes the sitemap to a response
+func (s *Sitemap) WriteTo(w io.Writer) (int64, error) {
+ if l := len(s.URLs); l > urlsLimit {
+ return 0, fmt.Errorf("The sitemap contains %d URLs, but only %d are allowed", l, urlsLimit)
+ }
+ if l := len(s.Sitemaps); l > urlsLimit {
+ return 0, fmt.Errorf("The sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit)
+ }
+ buf := bytes.NewBufferString(xml.Header)
+ if err := xml.NewEncoder(buf).Encode(s); err != nil {
+ return 0, err
+ }
+ if err := buf.WriteByte('\n'); err != nil {
+ return 0, err
+ }
+ if buf.Len() > sitemapFileLimit {
+ return 0, fmt.Errorf("The sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit)
+ }
+ return buf.WriteTo(w)
+}
diff --git a/modules/sitemap/sitemap_test.go b/modules/sitemap/sitemap_test.go
new file mode 100644
index 0000000..39a2178
--- /dev/null
+++ b/modules/sitemap/sitemap_test.go
@@ -0,0 +1,167 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sitemap
+
+import (
+ "bytes"
+ "encoding/xml"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewSitemap(t *testing.T) {
+ ts := time.Unix(1651322008, 0).UTC()
+
+ tests := []struct {
+ name string
+ urls []URL
+ want string
+ wantErr string
+ }{
+ {
+ name: "empty",
+ urls: []URL{},
+ want: xml.Header + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "" +
+ "</urlset>\n",
+ },
+ {
+ name: "regular",
+ urls: []URL{
+ {URL: "https://gitea.io/test1", LastMod: &ts},
+ },
+ want: xml.Header + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<url><loc>https://gitea.io/test1</loc><lastmod>2022-04-30T12:33:28Z</lastmod></url>" +
+ "</urlset>\n",
+ },
+ {
+ name: "without lastmod",
+ urls: []URL{
+ {URL: "https://gitea.io/test1"},
+ },
+ want: xml.Header + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<url><loc>https://gitea.io/test1</loc></url>" +
+ "</urlset>\n",
+ },
+ {
+ name: "multiple",
+ urls: []URL{
+ {URL: "https://gitea.io/test1", LastMod: &ts},
+ {URL: "https://gitea.io/test2", LastMod: nil},
+ },
+ want: xml.Header + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<url><loc>https://gitea.io/test1</loc><lastmod>2022-04-30T12:33:28Z</lastmod></url>" +
+ "<url><loc>https://gitea.io/test2</loc></url>" +
+ "</urlset>\n",
+ },
+ {
+ name: "too many urls",
+ urls: make([]URL, 50001),
+ wantErr: "The sitemap contains 50001 URLs, but only 50000 are allowed",
+ },
+ {
+ name: "too big file",
+ urls: []URL{
+ {URL: strings.Repeat("b", 50*1024*1024+1)},
+ },
+ wantErr: "The sitemap has 52428932 bytes, but only 52428800 are allowed",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := NewSitemap()
+ for _, url := range tt.urls {
+ s.Add(url)
+ }
+ buf := &bytes.Buffer{}
+ _, err := s.WriteTo(buf)
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ } else {
+ require.NoError(t, err)
+ assert.Equalf(t, tt.want, buf.String(), "NewSitemap()")
+ }
+ })
+ }
+}
+
+func TestNewSitemapIndex(t *testing.T) {
+ ts := time.Unix(1651322008, 0).UTC()
+
+ tests := []struct {
+ name string
+ urls []URL
+ want string
+ wantErr string
+ }{
+ {
+ name: "empty",
+ urls: []URL{},
+ want: xml.Header + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "" +
+ "</sitemapindex>\n",
+ },
+ {
+ name: "regular",
+ urls: []URL{
+ {URL: "https://gitea.io/test1", LastMod: &ts},
+ },
+ want: xml.Header + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<sitemap><loc>https://gitea.io/test1</loc><lastmod>2022-04-30T12:33:28Z</lastmod></sitemap>" +
+ "</sitemapindex>\n",
+ },
+ {
+ name: "without lastmod",
+ urls: []URL{
+ {URL: "https://gitea.io/test1"},
+ },
+ want: xml.Header + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<sitemap><loc>https://gitea.io/test1</loc></sitemap>" +
+ "</sitemapindex>\n",
+ },
+ {
+ name: "multiple",
+ urls: []URL{
+ {URL: "https://gitea.io/test1", LastMod: &ts},
+ {URL: "https://gitea.io/test2", LastMod: nil},
+ },
+ want: xml.Header + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
+ "<sitemap><loc>https://gitea.io/test1</loc><lastmod>2022-04-30T12:33:28Z</lastmod></sitemap>" +
+ "<sitemap><loc>https://gitea.io/test2</loc></sitemap>" +
+ "</sitemapindex>\n",
+ },
+ {
+ name: "too many sitemaps",
+ urls: make([]URL, 50001),
+ wantErr: "The sitemap contains 50001 sub-sitemaps, but only 50000 are allowed",
+ },
+ {
+ name: "too big file",
+ urls: []URL{
+ {URL: strings.Repeat("b", 50*1024*1024+1)},
+ },
+ wantErr: "The sitemap has 52428952 bytes, but only 52428800 are allowed",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := NewSitemapIndex()
+ for _, url := range tt.urls {
+ s.Add(url)
+ }
+ buf := &bytes.Buffer{}
+ _, err := s.WriteTo(buf)
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ } else {
+ require.NoError(t, err)
+ assert.Equalf(t, tt.want, buf.String(), "NewSitemapIndex()")
+ }
+ })
+ }
+}
diff --git a/modules/ssh/init.go b/modules/ssh/init.go
new file mode 100644
index 0000000..21d4f89
--- /dev/null
+++ b/modules/ssh/init.go
@@ -0,0 +1,55 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ssh
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func Init() error {
+ if setting.SSH.Disabled {
+ builtinUnused()
+ return nil
+ }
+
+ if setting.SSH.StartBuiltinServer {
+ Listen(setting.SSH.ListenHost, setting.SSH.ListenPort, setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs)
+ log.Info("SSH server started on %s. Cipher list (%v), key exchange algorithms (%v), MACs (%v)",
+ net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)),
+ setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs,
+ )
+ return nil
+ }
+
+ builtinUnused()
+
+ // FIXME: why 0o644 for a directory .....
+ if err := os.MkdirAll(setting.SSH.KeyTestPath, 0o644); err != nil {
+ return fmt.Errorf("failed to create directory %q for ssh key test: %w", setting.SSH.KeyTestPath, err)
+ }
+
+ if len(setting.SSH.TrustedUserCAKeys) > 0 && setting.SSH.AuthorizedPrincipalsEnabled {
+ caKeysFileName := setting.SSH.TrustedUserCAKeysFile
+ caKeysFileDir := filepath.Dir(caKeysFileName)
+
+ err := os.MkdirAll(caKeysFileDir, 0o700) // SSH.RootPath by default (That is `~/.ssh` in most cases)
+ if err != nil {
+ return fmt.Errorf("failed to create directory %q for ssh trusted ca keys: %w", caKeysFileDir, err)
+ }
+
+ if err := os.WriteFile(caKeysFileName, []byte(strings.Join(setting.SSH.TrustedUserCAKeys, "\n")), 0o600); err != nil {
+ return fmt.Errorf("failed to write ssh trusted ca keys to %q: %w", caKeysFileName, err)
+ }
+ }
+
+ return nil
+}
diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go
new file mode 100644
index 0000000..f8e4f56
--- /dev/null
+++ b/modules/ssh/ssh.go
@@ -0,0 +1,387 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ssh
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/gliderlabs/ssh"
+ gossh "golang.org/x/crypto/ssh"
+)
+
+type contextKey string
+
+const giteaKeyID = contextKey("gitea-key-id")
+
+func getExitStatusFromError(err error) int {
+ if err == nil {
+ return 0
+ }
+
+ exitErr, ok := err.(*exec.ExitError)
+ if !ok {
+ return 1
+ }
+
+ waitStatus, ok := exitErr.Sys().(syscall.WaitStatus)
+ if !ok {
+ // This is a fallback and should at least let us return something useful
+ // when running on Windows, even if it isn't completely accurate.
+ if exitErr.Success() {
+ return 0
+ }
+
+ return 1
+ }
+
+ return waitStatus.ExitStatus()
+}
+
+func sessionHandler(session ssh.Session) {
+ keyID := fmt.Sprintf("%d", session.Context().Value(giteaKeyID).(int64))
+
+ command := session.RawCommand()
+
+ log.Trace("SSH: Payload: %v", command)
+
+ args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID}
+ log.Trace("SSH: Arguments: %v", args)
+
+ ctx, cancel := context.WithCancel(session.Context())
+ defer cancel()
+
+ gitProtocol := ""
+ for _, env := range session.Environ() {
+ if strings.HasPrefix(env, "GIT_PROTOCOL=") {
+ _, gitProtocol, _ = strings.Cut(env, "=")
+ break
+ }
+ }
+
+ cmd := exec.CommandContext(ctx, setting.AppPath, args...)
+ cmd.Env = append(
+ os.Environ(),
+ "SSH_ORIGINAL_COMMAND="+command,
+ "SKIP_MINWINSVC=1",
+ "GIT_PROTOCOL="+gitProtocol,
+ )
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ log.Error("SSH: StdoutPipe: %v", err)
+ return
+ }
+ defer stdout.Close()
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ log.Error("SSH: StderrPipe: %v", err)
+ return
+ }
+ defer stderr.Close()
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ log.Error("SSH: StdinPipe: %v", err)
+ return
+ }
+ defer stdin.Close()
+
+ process.SetSysProcAttribute(cmd)
+
+ wg := &sync.WaitGroup{}
+ wg.Add(2)
+
+ if err = cmd.Start(); err != nil {
+ log.Error("SSH: Start: %v", err)
+ return
+ }
+
+ go func() {
+ defer stdin.Close()
+ if _, err := io.Copy(stdin, session); err != nil {
+ log.Error("Failed to write session to stdin. %s", err)
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer stdout.Close()
+ if _, err := io.Copy(session, stdout); err != nil {
+ log.Error("Failed to write stdout to session. %s", err)
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer stderr.Close()
+ if _, err := io.Copy(session.Stderr(), stderr); err != nil {
+ log.Error("Failed to write stderr to session. %s", err)
+ }
+ }()
+
+ // Ensure all the output has been written before we wait on the command
+ // to exit.
+ wg.Wait()
+
+ // Wait for the command to exit and log any errors we get
+ err = cmd.Wait()
+ if err != nil {
+ // Cannot use errors.Is here because ExitError doesn't implement Is
+ // Thus errors.Is will do equality test NOT type comparison
+ if _, ok := err.(*exec.ExitError); !ok {
+ log.Error("SSH: Wait: %v", err)
+ }
+ }
+
+ if err := session.Exit(getExitStatusFromError(err)); err != nil && !errors.Is(err, io.EOF) {
+ log.Error("Session failed to exit. %s", err)
+ }
+}
+
+func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
+ if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
+ log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr())
+ }
+
+ if ctx.User() != setting.SSH.BuiltinServerUser {
+ log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", ctx.User(), setting.SSH.BuiltinServerUser)
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+ return false
+ }
+
+ // check if we have a certificate
+ if cert, ok := key.(*gossh.Certificate); ok {
+ if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
+ log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
+ }
+
+ if len(setting.SSH.TrustedUserCAKeys) == 0 {
+ log.Warn("Certificate Rejected: No trusted certificate authorities for this server")
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+ return false
+ }
+
+ if cert.CertType != gossh.UserCert {
+ log.Warn("Certificate Rejected: Not a user certificate")
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+ return false
+ }
+
+ // look for the exact principal
+ principalLoop:
+ for _, principal := range cert.ValidPrincipals {
+ pkey, err := asymkey_model.SearchPublicKeyByContentExact(ctx, principal)
+ if err != nil {
+ if asymkey_model.IsErrKeyNotExist(err) {
+ log.Debug("Principal Rejected: %s Unknown Principal: %s", ctx.RemoteAddr(), principal)
+ continue principalLoop
+ }
+ log.Error("SearchPublicKeyByContentExact: %v", err)
+ return false
+ }
+
+ c := &gossh.CertChecker{
+ IsUserAuthority: func(auth gossh.PublicKey) bool {
+ marshaled := auth.Marshal()
+ for _, k := range setting.SSH.TrustedUserCAKeysParsed {
+ if bytes.Equal(marshaled, k.Marshal()) {
+ return true
+ }
+ }
+
+ return false
+ },
+ }
+
+ // check the CA of the cert
+ if !c.IsUserAuthority(cert.SignatureKey) {
+ if log.IsDebug() {
+ log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal)
+ }
+ continue principalLoop
+ }
+
+ // validate the cert for this principal
+ if err := c.CheckCert(principal, cert); err != nil {
+ // User is presenting an invalid certificate - STOP any further processing
+ log.Error("Invalid Certificate KeyID %s with Signature Fingerprint %s presented for Principal: %s from %s", cert.KeyId, gossh.FingerprintSHA256(cert.SignatureKey), principal, ctx.RemoteAddr())
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+
+ return false
+ }
+
+ if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
+ log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key), principal)
+ }
+ ctx.SetValue(giteaKeyID, pkey.ID)
+
+ return true
+ }
+
+ log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+ return false
+ }
+
+ if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
+ log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
+ }
+
+ pkey, err := asymkey_model.SearchPublicKeyByContent(ctx, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
+ if err != nil {
+ if asymkey_model.IsErrKeyNotExist(err) {
+ log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr())
+ log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr())
+ return false
+ }
+ log.Error("SearchPublicKeyByContent: %v", err)
+ return false
+ }
+
+ if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
+ log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key))
+ }
+ ctx.SetValue(giteaKeyID, pkey.ID)
+
+ return true
+}
+
+// sshConnectionFailed logs a failed connection
+// - this mainly exists to give a nice function name in logging
+func sshConnectionFailed(conn net.Conn, err error) {
+ // Log the underlying error with a specific message
+ log.Warn("Failed connection from %s with error: %v", conn.RemoteAddr(), err)
+ // Log with the standard failed authentication from message for simpler fail2ban configuration
+ log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
+}
+
+// Listen starts a SSH server listens on given port.
+func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
+ srv := ssh.Server{
+ Addr: net.JoinHostPort(host, strconv.Itoa(port)),
+ PublicKeyHandler: publicKeyHandler,
+ Handler: sessionHandler,
+ ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
+ config := &gossh.ServerConfig{}
+ config.KeyExchanges = keyExchanges
+ config.MACs = macs
+ config.Ciphers = ciphers
+ return config
+ },
+ ConnectionFailedCallback: sshConnectionFailed,
+ // We need to explicitly disable the PtyCallback so text displays
+ // properly.
+ PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
+ return false
+ },
+ }
+
+ keys := make([]string, 0, len(setting.SSH.ServerHostKeys))
+ for _, key := range setting.SSH.ServerHostKeys {
+ isExist, err := util.IsExist(key)
+ if err != nil {
+ log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
+ }
+ if isExist {
+ keys = append(keys, key)
+ }
+ }
+
+ if len(keys) == 0 {
+ filePath := filepath.Dir(setting.SSH.ServerHostKeys[0])
+
+ if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
+ log.Error("Failed to create dir %s: %v", filePath, err)
+ }
+
+ err := GenKeyPair(setting.SSH.ServerHostKeys[0])
+ if err != nil {
+ log.Fatal("Failed to generate private key: %v", err)
+ }
+ log.Trace("New private key is generated: %s", setting.SSH.ServerHostKeys[0])
+ keys = append(keys, setting.SSH.ServerHostKeys[0])
+ }
+
+ for _, key := range keys {
+ log.Info("Adding SSH host key: %s", key)
+ err := srv.SetOption(ssh.HostKeyFile(key))
+ if err != nil {
+ log.Error("Failed to set Host Key. %s", err)
+ }
+ }
+
+ go func() {
+ _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
+ defer finished()
+ listen(&srv)
+ }()
+}
+
+// GenKeyPair make a pair of public and private keys for SSH access.
+// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
+// Private Key generated is PEM encoded
+func GenKeyPair(keyPath string) error {
+ privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
+ if err != nil {
+ return err
+ }
+
+ privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
+ f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err = f.Close(); err != nil {
+ log.Error("Close: %v", err)
+ }
+ }()
+
+ if err := pem.Encode(f, privateKeyPEM); err != nil {
+ return err
+ }
+
+ // generate public key
+ pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
+ if err != nil {
+ return err
+ }
+
+ public := gossh.MarshalAuthorizedKey(pub)
+ p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err = p.Close(); err != nil {
+ log.Error("Close: %v", err)
+ }
+ }()
+ _, err = p.Write(public)
+ return err
+}
diff --git a/modules/ssh/ssh_graceful.go b/modules/ssh/ssh_graceful.go
new file mode 100644
index 0000000..cad2c68
--- /dev/null
+++ b/modules/ssh/ssh_graceful.go
@@ -0,0 +1,34 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ssh
+
+import (
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/gliderlabs/ssh"
+)
+
+func listen(server *ssh.Server) {
+ gracefulServer := graceful.NewServer("tcp", server.Addr, "SSH")
+ gracefulServer.PerWriteTimeout = setting.SSH.PerWriteTimeout
+ gracefulServer.PerWritePerKbTimeout = setting.SSH.PerWritePerKbTimeout
+
+ err := gracefulServer.ListenAndServe(server.Serve, setting.SSH.UseProxyProtocol)
+ if err != nil {
+ select {
+ case <-graceful.GetManager().IsShutdown():
+ log.Critical("Failed to start SSH server: %v", err)
+ default:
+ log.Fatal("Failed to start SSH server: %v", err)
+ }
+ }
+ log.Info("SSH Listener: %s Closed", server.Addr)
+}
+
+// builtinUnused informs our cleanup routine that we will not be using a ssh port
+func builtinUnused() {
+ graceful.GetManager().InformCleanup()
+}
diff --git a/modules/storage/helper.go b/modules/storage/helper.go
new file mode 100644
index 0000000..95f1c7b
--- /dev/null
+++ b/modules/storage/helper.go
@@ -0,0 +1,39 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+)
+
+var UninitializedStorage = DiscardStorage("uninitialized storage")
+
+type DiscardStorage string
+
+func (s DiscardStorage) Open(_ string) (Object, error) {
+ return nil, fmt.Errorf("%s", s)
+}
+
+func (s DiscardStorage) Save(_ string, _ io.Reader, _ int64) (int64, error) {
+ return 0, fmt.Errorf("%s", s)
+}
+
+func (s DiscardStorage) Stat(_ string) (os.FileInfo, error) {
+ return nil, fmt.Errorf("%s", s)
+}
+
+func (s DiscardStorage) Delete(_ string) error {
+ return fmt.Errorf("%s", s)
+}
+
+func (s DiscardStorage) URL(_, _ string) (*url.URL, error) {
+ return nil, fmt.Errorf("%s", s)
+}
+
+func (s DiscardStorage) IterateObjects(_ string, _ func(string, Object) error) error {
+ return fmt.Errorf("%s", s)
+}
diff --git a/modules/storage/helper_test.go b/modules/storage/helper_test.go
new file mode 100644
index 0000000..60a7c61
--- /dev/null
+++ b/modules/storage/helper_test.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_discardStorage(t *testing.T) {
+ tests := []DiscardStorage{
+ UninitializedStorage,
+ DiscardStorage("empty"),
+ }
+ for _, tt := range tests {
+ t.Run(string(tt), func(t *testing.T) {
+ {
+ got, err := tt.Open("path")
+ assert.Nil(t, got)
+ require.Error(t, err, string(tt))
+ }
+ {
+ got, err := tt.Save("path", bytes.NewReader([]byte{0}), 1)
+ assert.Equal(t, int64(0), got)
+ require.Error(t, err, string(tt))
+ }
+ {
+ got, err := tt.Stat("path")
+ assert.Nil(t, got)
+ require.Error(t, err, string(tt))
+ }
+ {
+ err := tt.Delete("path")
+ require.Error(t, err, string(tt))
+ }
+ {
+ got, err := tt.URL("path", "name")
+ assert.Nil(t, got)
+ require.Errorf(t, err, string(tt))
+ }
+ {
+ err := tt.IterateObjects("", func(_ string, _ Object) error { return nil })
+ require.Error(t, err, string(tt))
+ }
+ })
+ }
+}
diff --git a/modules/storage/local.go b/modules/storage/local.go
new file mode 100644
index 0000000..9bb532f
--- /dev/null
+++ b/modules/storage/local.go
@@ -0,0 +1,154 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+var _ ObjectStorage = &LocalStorage{}
+
+// LocalStorage represents a local files storage
+type LocalStorage struct {
+ ctx context.Context
+ dir string
+ tmpdir string
+}
+
+// NewLocalStorage returns a local files
+func NewLocalStorage(ctx context.Context, config *setting.Storage) (ObjectStorage, error) {
+ if !filepath.IsAbs(config.Path) {
+ return nil, fmt.Errorf("LocalStorageConfig.Path should have been prepared by setting/storage.go and should be an absolute path, but not: %q", config.Path)
+ }
+ log.Info("Creating new Local Storage at %s", config.Path)
+ if err := os.MkdirAll(config.Path, os.ModePerm); err != nil {
+ return nil, err
+ }
+
+ if config.TemporaryPath == "" {
+ config.TemporaryPath = filepath.Join(config.Path, "tmp")
+ }
+ if !filepath.IsAbs(config.TemporaryPath) {
+ return nil, fmt.Errorf("LocalStorageConfig.TemporaryPath should be an absolute path, but not: %q", config.TemporaryPath)
+ }
+
+ return &LocalStorage{
+ ctx: ctx,
+ dir: config.Path,
+ tmpdir: config.TemporaryPath,
+ }, nil
+}
+
+func (l *LocalStorage) buildLocalPath(p string) string {
+ return util.FilePathJoinAbs(l.dir, p)
+}
+
+// Open a file
+func (l *LocalStorage) Open(path string) (Object, error) {
+ return os.Open(l.buildLocalPath(path))
+}
+
+// Save a file
+func (l *LocalStorage) Save(path string, r io.Reader, size int64) (int64, error) {
+ p := l.buildLocalPath(path)
+ if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
+ return 0, err
+ }
+
+ // Create a temporary file to save to
+ if err := os.MkdirAll(l.tmpdir, os.ModePerm); err != nil {
+ return 0, err
+ }
+ tmp, err := os.CreateTemp(l.tmpdir, "upload-*")
+ if err != nil {
+ return 0, err
+ }
+ tmpRemoved := false
+ defer func() {
+ if !tmpRemoved {
+ _ = util.Remove(tmp.Name())
+ }
+ }()
+
+ n, err := io.Copy(tmp, r)
+ if err != nil {
+ return 0, err
+ }
+
+ if err := tmp.Close(); err != nil {
+ return 0, err
+ }
+
+ if err := util.Rename(tmp.Name(), p); err != nil {
+ return 0, err
+ }
+ // Golang's tmp file (os.CreateTemp) always have 0o600 mode, so we need to change the file to follow the umask (as what Create/MkDir does)
+ // but we don't want to make these files executable - so ensure that we mask out the executable bits
+ if err := util.ApplyUmask(p, os.ModePerm&0o666); err != nil {
+ return 0, err
+ }
+
+ tmpRemoved = true
+
+ return n, nil
+}
+
+// Stat returns the info of the file
+func (l *LocalStorage) Stat(path string) (os.FileInfo, error) {
+ return os.Stat(l.buildLocalPath(path))
+}
+
+// Delete delete a file
+func (l *LocalStorage) Delete(path string) error {
+ return util.Remove(l.buildLocalPath(path))
+}
+
+// URL gets the redirect URL to a file
+func (l *LocalStorage) URL(path, name string) (*url.URL, error) {
+ return nil, ErrURLNotSupported
+}
+
+// IterateObjects iterates across the objects in the local storage
+func (l *LocalStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
+ dir := l.buildLocalPath(dirName)
+ return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ select {
+ case <-l.ctx.Done():
+ return l.ctx.Err()
+ default:
+ }
+ if path == l.dir {
+ return nil
+ }
+ if d.IsDir() {
+ return nil
+ }
+ relPath, err := filepath.Rel(l.dir, path)
+ if err != nil {
+ return err
+ }
+ obj, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer obj.Close()
+ return fn(relPath, obj)
+ })
+}
+
+func init() {
+ RegisterStorageType(setting.LocalStorageType, NewLocalStorage)
+}
diff --git a/modules/storage/local_test.go b/modules/storage/local_test.go
new file mode 100644
index 0000000..e230323
--- /dev/null
+++ b/modules/storage/local_test.go
@@ -0,0 +1,61 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBuildLocalPath(t *testing.T) {
+ kases := []struct {
+ localDir string
+ path string
+ expected string
+ }{
+ {
+ "/a",
+ "0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ "/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ },
+ {
+ "/a",
+ "../0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ "/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ },
+ {
+ "/a",
+ "0\\a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ "/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ },
+ {
+ "/b",
+ "a/../0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ "/b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ },
+ {
+ "/b",
+ "a\\..\\0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ "/b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
+ },
+ }
+
+ for _, k := range kases {
+ t.Run(k.path, func(t *testing.T) {
+ l := LocalStorage{dir: k.localDir}
+
+ assert.EqualValues(t, k.expected, l.buildLocalPath(k.path))
+ })
+ }
+}
+
+func TestLocalStorageIterator(t *testing.T) {
+ dir := filepath.Join(os.TempDir(), "TestLocalStorageIteratorTestDir")
+ testStorageIterator(t, setting.LocalStorageType, &setting.Storage{Path: dir})
+}
diff --git a/modules/storage/minio.go b/modules/storage/minio.go
new file mode 100644
index 0000000..d0c2dec
--- /dev/null
+++ b/modules/storage/minio.go
@@ -0,0 +1,310 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/minio/minio-go/v7"
+ "github.com/minio/minio-go/v7/pkg/credentials"
+)
+
+var (
+ _ ObjectStorage = &MinioStorage{}
+
+ quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
+)
+
+type minioObject struct {
+ *minio.Object
+}
+
+func (m *minioObject) Stat() (os.FileInfo, error) {
+ oi, err := m.Object.Stat()
+ if err != nil {
+ return nil, convertMinioErr(err)
+ }
+
+ return &minioFileInfo{oi}, nil
+}
+
+// MinioStorage returns a minio bucket storage
+type MinioStorage struct {
+ cfg *setting.MinioStorageConfig
+ ctx context.Context
+ client *minio.Client
+ bucket string
+ basePath string
+}
+
+func convertMinioErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ errResp, ok := err.(minio.ErrorResponse)
+ if !ok {
+ return err
+ }
+
+ // Convert two responses to standard analogues
+ switch errResp.Code {
+ case "NoSuchKey":
+ return os.ErrNotExist
+ case "AccessDenied":
+ return os.ErrPermission
+ }
+
+ return err
+}
+
+var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
+ _, err := minioClient.GetBucketVersioning(ctx, bucket)
+ return err
+}
+
+// NewMinioStorage returns a minio storage
+func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
+ config := cfg.MinioConfig
+ if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
+ return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
+ }
+ var lookup minio.BucketLookupType
+ switch config.BucketLookup {
+ case "auto", "":
+ lookup = minio.BucketLookupAuto
+ case "dns":
+ lookup = minio.BucketLookupDNS
+ case "path":
+ lookup = minio.BucketLookupPath
+ default:
+ return nil, fmt.Errorf("invalid minio bucket lookup type %s", config.BucketLookup)
+ }
+
+ log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
+
+ minioClient, err := minio.New(config.Endpoint, &minio.Options{
+ Creds: buildMinioCredentials(config, credentials.DefaultIAMRoleEndpoint),
+ Secure: config.UseSSL,
+ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: config.InsecureSkipVerify}},
+ Region: config.Location,
+ BucketLookup: lookup,
+ })
+ if err != nil {
+ return nil, convertMinioErr(err)
+ }
+
+ // The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
+ // The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
+ // Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
+ // Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
+ // Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
+ err = getBucketVersioning(ctx, minioClient, config.Bucket)
+ if err != nil {
+ errResp, ok := err.(minio.ErrorResponse)
+ if !ok {
+ return nil, err
+ }
+ if errResp.StatusCode == http.StatusBadRequest {
+ log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
+ return nil, err
+ }
+ }
+
+ // Check to see if we already own this bucket
+ exists, err := minioClient.BucketExists(ctx, config.Bucket)
+ if err != nil {
+ return nil, convertMinioErr(err)
+ }
+
+ if !exists {
+ if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
+ Region: config.Location,
+ }); err != nil {
+ return nil, convertMinioErr(err)
+ }
+ }
+
+ return &MinioStorage{
+ cfg: &config,
+ ctx: ctx,
+ client: minioClient,
+ bucket: config.Bucket,
+ basePath: config.BasePath,
+ }, nil
+}
+
+func (m *MinioStorage) buildMinioPath(p string) string {
+ p = strings.TrimPrefix(util.PathJoinRelX(m.basePath, p), "/") // object store doesn't use slash for root path
+ if p == "." {
+ p = "" // object store doesn't use dot as relative path
+ }
+ return p
+}
+
+func (m *MinioStorage) buildMinioDirPrefix(p string) string {
+ // ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
+ p = m.buildMinioPath(p) + "/"
+ if p == "/" {
+ p = "" // object store doesn't use slash for root path
+ }
+ return p
+}
+
+func buildMinioCredentials(config setting.MinioStorageConfig, iamEndpoint string) *credentials.Credentials {
+ // If static credentials are provided, use those
+ if config.AccessKeyID != "" {
+ return credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, "")
+ }
+
+ // Otherwise, fallback to a credentials chain for S3 access
+ chain := []credentials.Provider{
+ // configure based upon MINIO_ prefixed environment variables
+ &credentials.EnvMinio{},
+ // configure based upon AWS_ prefixed environment variables
+ &credentials.EnvAWS{},
+ // read credentials from MINIO_SHARED_CREDENTIALS_FILE
+ // environment variable, or default json config files
+ &credentials.FileMinioClient{},
+ // read credentials from AWS_SHARED_CREDENTIALS_FILE
+ // environment variable, or default credentials file
+ &credentials.FileAWSCredentials{},
+ // read IAM role from EC2 metadata endpoint if available
+ &credentials.IAM{
+ Endpoint: iamEndpoint,
+ Client: &http.Client{
+ Transport: http.DefaultTransport,
+ },
+ },
+ }
+ return credentials.NewChainCredentials(chain)
+}
+
+// Open opens a file
+func (m *MinioStorage) Open(path string) (Object, error) {
+ opts := minio.GetObjectOptions{}
+ object, err := m.client.GetObject(m.ctx, m.bucket, m.buildMinioPath(path), opts)
+ if err != nil {
+ return nil, convertMinioErr(err)
+ }
+ return &minioObject{object}, nil
+}
+
+// Save saves a file to minio
+func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error) {
+ uploadInfo, err := m.client.PutObject(
+ m.ctx,
+ m.bucket,
+ m.buildMinioPath(path),
+ r,
+ size,
+ minio.PutObjectOptions{
+ ContentType: "application/octet-stream",
+ // some storages like:
+ // * https://developers.cloudflare.com/r2/api/s3/api/
+ // * https://www.backblaze.com/b2/docs/s3_compatible_api.html
+ // do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
+ SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
+ },
+ )
+ if err != nil {
+ return 0, convertMinioErr(err)
+ }
+ return uploadInfo.Size, nil
+}
+
+type minioFileInfo struct {
+ minio.ObjectInfo
+}
+
+func (m minioFileInfo) Name() string {
+ return path.Base(m.ObjectInfo.Key)
+}
+
+func (m minioFileInfo) Size() int64 {
+ return m.ObjectInfo.Size
+}
+
+func (m minioFileInfo) ModTime() time.Time {
+ return m.LastModified
+}
+
+func (m minioFileInfo) IsDir() bool {
+ return strings.HasSuffix(m.ObjectInfo.Key, "/")
+}
+
+func (m minioFileInfo) Mode() os.FileMode {
+ return os.ModePerm
+}
+
+func (m minioFileInfo) Sys() any {
+ return nil
+}
+
+// Stat returns the stat information of the object
+func (m *MinioStorage) Stat(path string) (os.FileInfo, error) {
+ info, err := m.client.StatObject(
+ m.ctx,
+ m.bucket,
+ m.buildMinioPath(path),
+ minio.StatObjectOptions{},
+ )
+ if err != nil {
+ return nil, convertMinioErr(err)
+ }
+ return &minioFileInfo{info}, nil
+}
+
+// Delete delete a file
+func (m *MinioStorage) Delete(path string) error {
+ err := m.client.RemoveObject(m.ctx, m.bucket, m.buildMinioPath(path), minio.RemoveObjectOptions{})
+
+ return convertMinioErr(err)
+}
+
+// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
+func (m *MinioStorage) URL(path, name string) (*url.URL, error) {
+ reqParams := make(url.Values)
+ // TODO it may be good to embed images with 'inline' like ServeData does, but we don't want to have to read the file, do we?
+ reqParams.Set("response-content-disposition", "attachment; filename=\""+quoteEscaper.Replace(name)+"\"")
+ u, err := m.client.PresignedGetObject(m.ctx, m.bucket, m.buildMinioPath(path), 5*time.Minute, reqParams)
+ return u, convertMinioErr(err)
+}
+
+// IterateObjects iterates across the objects in the miniostorage
+func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
+ opts := minio.GetObjectOptions{}
+ for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
+ Prefix: m.buildMinioDirPrefix(dirName),
+ Recursive: true,
+ }) {
+ object, err := m.client.GetObject(m.ctx, m.bucket, mObjInfo.Key, opts)
+ if err != nil {
+ return convertMinioErr(err)
+ }
+ if err := func(object *minio.Object, fn func(path string, obj Object) error) error {
+ defer object.Close()
+ return fn(strings.TrimPrefix(mObjInfo.Key, m.basePath), &minioObject{object})
+ }(object, fn); err != nil {
+ return convertMinioErr(err)
+ }
+ }
+ return nil
+}
+
+func init() {
+ RegisterStorageType(setting.MinioStorageType, NewMinioStorage)
+}
diff --git a/modules/storage/minio_test.go b/modules/storage/minio_test.go
new file mode 100644
index 0000000..9ce1dbc
--- /dev/null
+++ b/modules/storage/minio_test.go
@@ -0,0 +1,216 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/minio/minio-go/v7"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMinioStorageIterator(t *testing.T) {
+ if os.Getenv("CI") == "" {
+ t.Skip("minioStorage not present outside of CI")
+ return
+ }
+ testStorageIterator(t, setting.MinioStorageType, &setting.Storage{
+ MinioConfig: setting.MinioStorageConfig{
+ Endpoint: "minio:9000",
+ AccessKeyID: "123456",
+ SecretAccessKey: "12345678",
+ Bucket: "gitea",
+ Location: "us-east-1",
+ },
+ })
+}
+
+func TestVirtualHostMinioStorage(t *testing.T) {
+ if os.Getenv("CI") == "" {
+ t.Skip("minioStorage not present outside of CI")
+ return
+ }
+ testStorageIterator(t, setting.MinioStorageType, &setting.Storage{
+ MinioConfig: setting.MinioStorageConfig{
+ Endpoint: "minio:9000",
+ AccessKeyID: "123456",
+ SecretAccessKey: "12345678",
+ Bucket: "gitea",
+ Location: "us-east-1",
+ BucketLookup: "dns",
+ },
+ })
+}
+
+func TestMinioStoragePath(t *testing.T) {
+ m := &MinioStorage{basePath: ""}
+ assert.Equal(t, "", m.buildMinioPath("/"))
+ assert.Equal(t, "", m.buildMinioPath("."))
+ assert.Equal(t, "a", m.buildMinioPath("/a"))
+ assert.Equal(t, "a/b", m.buildMinioPath("/a/b/"))
+ assert.Equal(t, "", m.buildMinioDirPrefix(""))
+ assert.Equal(t, "a/", m.buildMinioDirPrefix("/a/"))
+
+ m = &MinioStorage{basePath: "/"}
+ assert.Equal(t, "", m.buildMinioPath("/"))
+ assert.Equal(t, "", m.buildMinioPath("."))
+ assert.Equal(t, "a", m.buildMinioPath("/a"))
+ assert.Equal(t, "a/b", m.buildMinioPath("/a/b/"))
+ assert.Equal(t, "", m.buildMinioDirPrefix(""))
+ assert.Equal(t, "a/", m.buildMinioDirPrefix("/a/"))
+
+ m = &MinioStorage{basePath: "/base"}
+ assert.Equal(t, "base", m.buildMinioPath("/"))
+ assert.Equal(t, "base", m.buildMinioPath("."))
+ assert.Equal(t, "base/a", m.buildMinioPath("/a"))
+ assert.Equal(t, "base/a/b", m.buildMinioPath("/a/b/"))
+ assert.Equal(t, "base/", m.buildMinioDirPrefix(""))
+ assert.Equal(t, "base/a/", m.buildMinioDirPrefix("/a/"))
+
+ m = &MinioStorage{basePath: "/base/"}
+ assert.Equal(t, "base", m.buildMinioPath("/"))
+ assert.Equal(t, "base", m.buildMinioPath("."))
+ assert.Equal(t, "base/a", m.buildMinioPath("/a"))
+ assert.Equal(t, "base/a/b", m.buildMinioPath("/a/b/"))
+ assert.Equal(t, "base/", m.buildMinioDirPrefix(""))
+ assert.Equal(t, "base/a/", m.buildMinioDirPrefix("/a/"))
+}
+
+func TestS3StorageBadRequest(t *testing.T) {
+ if os.Getenv("CI") == "" {
+ t.Skip("S3Storage not present outside of CI")
+ return
+ }
+ cfg := &setting.Storage{
+ MinioConfig: setting.MinioStorageConfig{
+ Endpoint: "minio:9000",
+ AccessKeyID: "123456",
+ SecretAccessKey: "12345678",
+ Bucket: "bucket",
+ Location: "us-east-1",
+ },
+ }
+ message := "ERROR"
+ old := getBucketVersioning
+ defer func() { getBucketVersioning = old }()
+ getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
+ return minio.ErrorResponse{
+ StatusCode: http.StatusBadRequest,
+ Code: "FixtureError",
+ Message: message,
+ }
+ }
+ _, err := NewStorage(setting.MinioStorageType, cfg)
+ require.ErrorContains(t, err, message)
+}
+
+func TestMinioCredentials(t *testing.T) {
+ const (
+ ExpectedAccessKey = "ExampleAccessKeyID"
+ ExpectedSecretAccessKey = "ExampleSecretAccessKeyID"
+ // Use a FakeEndpoint for IAM credentials to avoid logging any
+ // potential real IAM credentials when running in EC2.
+ FakeEndpoint = "http://localhost"
+ )
+
+ t.Run("Static Credentials", func(t *testing.T) {
+ cfg := setting.MinioStorageConfig{
+ AccessKeyID: ExpectedAccessKey,
+ SecretAccessKey: ExpectedSecretAccessKey,
+ }
+ creds := buildMinioCredentials(cfg, FakeEndpoint)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey, v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey, v.SecretAccessKey)
+ })
+
+ t.Run("Chain", func(t *testing.T) {
+ cfg := setting.MinioStorageConfig{}
+
+ t.Run("EnvMinio", func(t *testing.T) {
+ t.Setenv("MINIO_ACCESS_KEY", ExpectedAccessKey+"Minio")
+ t.Setenv("MINIO_SECRET_KEY", ExpectedSecretAccessKey+"Minio")
+
+ creds := buildMinioCredentials(cfg, FakeEndpoint)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey+"Minio", v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey+"Minio", v.SecretAccessKey)
+ })
+
+ t.Run("EnvAWS", func(t *testing.T) {
+ t.Setenv("AWS_ACCESS_KEY", ExpectedAccessKey+"AWS")
+ t.Setenv("AWS_SECRET_KEY", ExpectedSecretAccessKey+"AWS")
+
+ creds := buildMinioCredentials(cfg, FakeEndpoint)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey+"AWS", v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey+"AWS", v.SecretAccessKey)
+ })
+
+ t.Run("FileMinio", func(t *testing.T) {
+ t.Setenv("MINIO_SHARED_CREDENTIALS_FILE", "testdata/minio.json")
+ // prevent loading any actual credentials files from the user
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "testdata/fake")
+
+ creds := buildMinioCredentials(cfg, FakeEndpoint)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey+"MinioFile", v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey+"MinioFile", v.SecretAccessKey)
+ })
+
+ t.Run("FileAWS", func(t *testing.T) {
+ // prevent loading any actual credentials files from the user
+ t.Setenv("MINIO_SHARED_CREDENTIALS_FILE", "testdata/fake.json")
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "testdata/aws_credentials")
+
+ creds := buildMinioCredentials(cfg, FakeEndpoint)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey+"AWSFile", v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey+"AWSFile", v.SecretAccessKey)
+ })
+
+ t.Run("IAM", func(t *testing.T) {
+ // prevent loading any actual credentials files from the user
+ t.Setenv("MINIO_SHARED_CREDENTIALS_FILE", "testdata/fake.json")
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "testdata/fake")
+
+ // Spawn a server to emulate the EC2 Instance Metadata
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The client will actually make 3 requests here,
+ // first will be to get the IMDSv2 token, second to
+ // get the role, and third for the actual
+ // credentials. However, we can return credentials
+ // every request since we're not emulating a full
+ // IMDSv2 flow.
+ w.Write([]byte(`{"Code":"Success","AccessKeyId":"ExampleAccessKeyIDIAM","SecretAccessKey":"ExampleSecretAccessKeyIDIAM"}`))
+ }))
+ defer server.Close()
+
+ // Use the provided EC2 Instance Metadata server
+ creds := buildMinioCredentials(cfg, server.URL)
+ v, err := creds.Get()
+
+ require.NoError(t, err)
+ assert.Equal(t, ExpectedAccessKey+"IAM", v.AccessKeyID)
+ assert.Equal(t, ExpectedSecretAccessKey+"IAM", v.SecretAccessKey)
+ })
+ })
+}
diff --git a/modules/storage/storage.go b/modules/storage/storage.go
new file mode 100644
index 0000000..b83b1c7
--- /dev/null
+++ b/modules/storage/storage.go
@@ -0,0 +1,226 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// ErrURLNotSupported represents url is not supported
+var ErrURLNotSupported = errors.New("url method not supported")
+
+// ErrInvalidConfiguration is called when there is invalid configuration for a storage
+type ErrInvalidConfiguration struct {
+ cfg any
+ err error
+}
+
+func (err ErrInvalidConfiguration) Error() string {
+ if err.err != nil {
+ return fmt.Sprintf("Invalid Configuration Argument: %v: Error: %v", err.cfg, err.err)
+ }
+ return fmt.Sprintf("Invalid Configuration Argument: %v", err.cfg)
+}
+
+// IsErrInvalidConfiguration checks if an error is an ErrInvalidConfiguration
+func IsErrInvalidConfiguration(err error) bool {
+ _, ok := err.(ErrInvalidConfiguration)
+ return ok
+}
+
+type Type = setting.StorageType
+
+// NewStorageFunc is a function that creates a storage
+type NewStorageFunc func(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error)
+
+var storageMap = map[Type]NewStorageFunc{}
+
+// RegisterStorageType registers a provided storage type with a function to create it
+func RegisterStorageType(typ Type, fn func(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error)) {
+ storageMap[typ] = fn
+}
+
+// Object represents the object on the storage
+type Object interface {
+ io.ReadCloser
+ io.Seeker
+ Stat() (os.FileInfo, error)
+}
+
+// ObjectStorage represents an object storage to handle a bucket and files
+type ObjectStorage interface {
+ Open(path string) (Object, error)
+ // Save store a object, if size is unknown set -1
+ Save(path string, r io.Reader, size int64) (int64, error)
+ Stat(path string) (os.FileInfo, error)
+ Delete(path string) error
+ URL(path, name string) (*url.URL, error)
+ IterateObjects(path string, iterator func(path string, obj Object) error) error
+}
+
+// Copy copies a file from source ObjectStorage to dest ObjectStorage
+func Copy(dstStorage ObjectStorage, dstPath string, srcStorage ObjectStorage, srcPath string) (int64, error) {
+ f, err := srcStorage.Open(srcPath)
+ if err != nil {
+ return 0, err
+ }
+ defer f.Close()
+
+ size := int64(-1)
+ fsinfo, err := f.Stat()
+ if err == nil {
+ size = fsinfo.Size()
+ }
+
+ return dstStorage.Save(dstPath, f, size)
+}
+
+// Clean delete all the objects in this storage
+func Clean(storage ObjectStorage) error {
+ return storage.IterateObjects("", func(path string, obj Object) error {
+ _ = obj.Close()
+ return storage.Delete(path)
+ })
+}
+
+// SaveFrom saves data to the ObjectStorage with path p from the callback
+func SaveFrom(objStorage ObjectStorage, p string, callback func(w io.Writer) error) error {
+ pr, pw := io.Pipe()
+ defer pr.Close()
+ go func() {
+ defer pw.Close()
+ if err := callback(pw); err != nil {
+ _ = pw.CloseWithError(err)
+ }
+ }()
+
+ _, err := objStorage.Save(p, pr, -1)
+ return err
+}
+
+var (
+ // Attachments represents attachments storage
+ Attachments ObjectStorage = UninitializedStorage
+
+ // LFS represents lfs storage
+ LFS ObjectStorage = UninitializedStorage
+
+ // Avatars represents user avatars storage
+ Avatars ObjectStorage = UninitializedStorage
+ // RepoAvatars represents repository avatars storage
+ RepoAvatars ObjectStorage = UninitializedStorage
+
+ // RepoArchives represents repository archives storage
+ RepoArchives ObjectStorage = UninitializedStorage
+
+ // Packages represents packages storage
+ Packages ObjectStorage = UninitializedStorage
+
+ // Actions represents actions storage
+ Actions ObjectStorage = UninitializedStorage
+ // Actions Artifacts represents actions artifacts storage
+ ActionsArtifacts ObjectStorage = UninitializedStorage
+)
+
+// Init init the stoarge
+func Init() error {
+ for _, f := range []func() error{
+ initAttachments,
+ initAvatars,
+ initRepoAvatars,
+ initLFS,
+ initRepoArchives,
+ initPackages,
+ initActions,
+ } {
+ if err := f(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// NewStorage takes a storage type and some config and returns an ObjectStorage or an error
+func NewStorage(typStr Type, cfg *setting.Storage) (ObjectStorage, error) {
+ if len(typStr) == 0 {
+ typStr = setting.LocalStorageType
+ }
+ fn, ok := storageMap[typStr]
+ if !ok {
+ return nil, fmt.Errorf("Unsupported storage type: %s", typStr)
+ }
+
+ return fn(context.Background(), cfg)
+}
+
+func initAvatars() (err error) {
+ log.Info("Initialising Avatar storage with type: %s", setting.Avatar.Storage.Type)
+ Avatars, err = NewStorage(setting.Avatar.Storage.Type, setting.Avatar.Storage)
+ return err
+}
+
+func initAttachments() (err error) {
+ if !setting.Attachment.Enabled {
+ Attachments = DiscardStorage("Attachment isn't enabled")
+ return nil
+ }
+ log.Info("Initialising Attachment storage with type: %s", setting.Attachment.Storage.Type)
+ Attachments, err = NewStorage(setting.Attachment.Storage.Type, setting.Attachment.Storage)
+ return err
+}
+
+func initLFS() (err error) {
+ if !setting.LFS.StartServer {
+ LFS = DiscardStorage("LFS isn't enabled")
+ return nil
+ }
+ log.Info("Initialising LFS storage with type: %s", setting.LFS.Storage.Type)
+ LFS, err = NewStorage(setting.LFS.Storage.Type, setting.LFS.Storage)
+ return err
+}
+
+func initRepoAvatars() (err error) {
+ log.Info("Initialising Repository Avatar storage with type: %s", setting.RepoAvatar.Storage.Type)
+ RepoAvatars, err = NewStorage(setting.RepoAvatar.Storage.Type, setting.RepoAvatar.Storage)
+ return err
+}
+
+func initRepoArchives() (err error) {
+ log.Info("Initialising Repository Archive storage with type: %s", setting.RepoArchive.Storage.Type)
+ RepoArchives, err = NewStorage(setting.RepoArchive.Storage.Type, setting.RepoArchive.Storage)
+ return err
+}
+
+func initPackages() (err error) {
+ if !setting.Packages.Enabled {
+ Packages = DiscardStorage("Packages isn't enabled")
+ return nil
+ }
+ log.Info("Initialising Packages storage with type: %s", setting.Packages.Storage.Type)
+ Packages, err = NewStorage(setting.Packages.Storage.Type, setting.Packages.Storage)
+ return err
+}
+
+func initActions() (err error) {
+ if !setting.Actions.Enabled {
+ Actions = DiscardStorage("Actions isn't enabled")
+ ActionsArtifacts = DiscardStorage("ActionsArtifacts isn't enabled")
+ return nil
+ }
+ log.Info("Initialising Actions storage with type: %s", setting.Actions.LogStorage.Type)
+ if Actions, err = NewStorage(setting.Actions.LogStorage.Type, setting.Actions.LogStorage); err != nil {
+ return err
+ }
+ log.Info("Initialising ActionsArtifacts storage with type: %s", setting.Actions.ArtifactStorage.Type)
+ ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, setting.Actions.ArtifactStorage)
+ return err
+}
diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go
new file mode 100644
index 0000000..70bcd31
--- /dev/null
+++ b/modules/storage/storage_test.go
@@ -0,0 +1,52 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package storage
+
+import (
+ "bytes"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
+ l, err := NewStorage(typStr, cfg)
+ require.NoError(t, err)
+
+ testFiles := [][]string{
+ {"a/1.txt", "a1"},
+ {"/a/1.txt", "aa1"}, // same as above, but with leading slash that will be trim
+ {"ab/1.txt", "ab1"},
+ {"b/1.txt", "b1"},
+ {"b/2.txt", "b2"},
+ {"b/3.txt", "b3"},
+ {"b/x 4.txt", "bx4"},
+ }
+ for _, f := range testFiles {
+ _, err = l.Save(f[0], bytes.NewBufferString(f[1]), -1)
+ require.NoError(t, err)
+ }
+
+ expectedList := map[string][]string{
+ "a": {"a/1.txt"},
+ "b": {"b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt"},
+ "": {"a/1.txt", "b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt", "ab/1.txt"},
+ "/": {"a/1.txt", "b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt", "ab/1.txt"},
+ "a/b/../../a": {"a/1.txt"},
+ }
+ for dir, expected := range expectedList {
+ count := 0
+ err = l.IterateObjects(dir, func(path string, f Object) error {
+ defer f.Close()
+ assert.Contains(t, expected, path)
+ count++
+ return nil
+ })
+ require.NoError(t, err)
+ assert.Len(t, expected, count)
+ }
+}
diff --git a/modules/storage/testdata/aws_credentials b/modules/storage/testdata/aws_credentials
new file mode 100644
index 0000000..62a5488
--- /dev/null
+++ b/modules/storage/testdata/aws_credentials
@@ -0,0 +1,3 @@
+[default]
+aws_access_key_id=ExampleAccessKeyIDAWSFile
+aws_secret_access_key=ExampleSecretAccessKeyIDAWSFile
diff --git a/modules/storage/testdata/minio.json b/modules/storage/testdata/minio.json
new file mode 100644
index 0000000..3876257
--- /dev/null
+++ b/modules/storage/testdata/minio.json
@@ -0,0 +1,12 @@
+{
+ "version": "10",
+ "aliases": {
+ "s3": {
+ "url": "https://s3.amazonaws.com",
+ "accessKey": "ExampleAccessKeyIDMinioFile",
+ "secretKey": "ExampleSecretAccessKeyIDMinioFile",
+ "api": "S3v4",
+ "path": "dns"
+ }
+ }
+}
diff --git a/modules/structs/activity.go b/modules/structs/activity.go
new file mode 100644
index 0000000..1bb8313
--- /dev/null
+++ b/modules/structs/activity.go
@@ -0,0 +1,25 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+type Activity struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"` // Receiver user
+ // the type of action
+ //
+ // enum: ["create_repo", "rename_repo", "star_repo", "watch_repo", "commit_repo", "create_issue", "create_pull_request", "transfer_repo", "push_tag", "comment_issue", "merge_pull_request", "close_issue", "reopen_issue", "close_pull_request", "reopen_pull_request", "delete_tag", "delete_branch", "mirror_sync_push", "mirror_sync_create", "mirror_sync_delete", "approve_pull_request", "reject_pull_request", "comment_pull", "publish_release", "pull_review_dismissed", "pull_request_ready_for_review", "auto_merge_pull_request"]
+ OpType string `json:"op_type"`
+ ActUserID int64 `json:"act_user_id"`
+ ActUser *User `json:"act_user"`
+ RepoID int64 `json:"repo_id"`
+ Repo *Repository `json:"repo"`
+ CommentID int64 `json:"comment_id"`
+ Comment *Comment `json:"comment"`
+ RefName string `json:"ref_name"`
+ IsPrivate bool `json:"is_private"`
+ Content string `json:"content"`
+ Created time.Time `json:"created"`
+}
diff --git a/modules/structs/activitypub.go b/modules/structs/activitypub.go
new file mode 100644
index 0000000..117eb0b
--- /dev/null
+++ b/modules/structs/activitypub.go
@@ -0,0 +1,9 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// ActivityPub type
+type ActivityPub struct {
+ Context string `json:"@context"`
+}
diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go
new file mode 100644
index 0000000..5b7df12
--- /dev/null
+++ b/modules/structs/admin_user.go
@@ -0,0 +1,53 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// CreateUserOption create user options
+type CreateUserOption struct {
+ SourceID int64 `json:"source_id"`
+ LoginName string `json:"login_name"`
+ // required: true
+ Username string `json:"username" binding:"Required;Username;MaxSize(40)"`
+ FullName string `json:"full_name" binding:"MaxSize(100)"`
+ // required: true
+ // swagger:strfmt email
+ Email string `json:"email" binding:"Required;Email;MaxSize(254)"`
+ Password string `json:"password" binding:"MaxSize(255)"`
+ MustChangePassword *bool `json:"must_change_password"`
+ SendNotify bool `json:"send_notify"`
+ Restricted *bool `json:"restricted"`
+ Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
+
+ // For explicitly setting the user creation timestamp. Useful when users are
+ // migrated from other systems. When omitted, the user's creation timestamp
+ // will be set to "now".
+ Created *time.Time `json:"created_at"`
+}
+
+// EditUserOption edit user options
+type EditUserOption struct {
+ SourceID *int64 `json:"source_id"`
+ LoginName *string `json:"login_name"`
+ // swagger:strfmt email
+ Email *string `json:"email" binding:"MaxSize(254)"`
+ FullName *string `json:"full_name" binding:"MaxSize(100)"`
+ Password string `json:"password" binding:"MaxSize(255)"`
+ MustChangePassword *bool `json:"must_change_password"`
+ Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
+ Location *string `json:"location" binding:"MaxSize(50)"`
+ Pronouns *string `json:"pronouns" binding:"MaxSize(50)"`
+ Description *string `json:"description" binding:"MaxSize(255)"`
+ Active *bool `json:"active"`
+ Admin *bool `json:"admin"`
+ AllowGitHook *bool `json:"allow_git_hook"`
+ AllowImportLocal *bool `json:"allow_import_local"`
+ MaxRepoCreation *int `json:"max_repo_creation"`
+ ProhibitLogin *bool `json:"prohibit_login"`
+ AllowCreateOrganization *bool `json:"allow_create_organization"`
+ Restricted *bool `json:"restricted"`
+ Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
+}
diff --git a/modules/structs/attachment.go b/modules/structs/attachment.go
new file mode 100644
index 0000000..c97cdcb
--- /dev/null
+++ b/modules/structs/attachment.go
@@ -0,0 +1,31 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs // import "code.gitea.io/gitea/modules/structs"
+
+import (
+ "time"
+)
+
+// Attachment a generic attachment
+// swagger:model
+type Attachment struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ DownloadCount int64 `json:"download_count"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ UUID string `json:"uuid"`
+ DownloadURL string `json:"browser_download_url"`
+ // enum: ["attachment", "external"]
+ Type string `json:"type"`
+}
+
+// EditAttachmentOptions options for editing attachments
+// swagger:model
+type EditAttachmentOptions struct {
+ Name string `json:"name"`
+ // (Can only be set if existing attachment is of external type)
+ DownloadURL string `json:"browser_download_url"`
+}
diff --git a/modules/structs/commit_status.go b/modules/structs/commit_status.go
new file mode 100644
index 0000000..dc880ef
--- /dev/null
+++ b/modules/structs/commit_status.go
@@ -0,0 +1,73 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// CommitStatusState holds the state of a CommitStatus
+// It can be "pending", "success", "error" and "failure"
+type CommitStatusState string
+
+const (
+ // CommitStatusPending is for when the CommitStatus is Pending
+ CommitStatusPending CommitStatusState = "pending"
+ // CommitStatusSuccess is for when the CommitStatus is Success
+ CommitStatusSuccess CommitStatusState = "success"
+ // CommitStatusError is for when the CommitStatus is Error
+ CommitStatusError CommitStatusState = "error"
+ // CommitStatusFailure is for when the CommitStatus is Failure
+ CommitStatusFailure CommitStatusState = "failure"
+ // CommitStatusWarning is for when the CommitStatus is Warning
+ CommitStatusWarning CommitStatusState = "warning"
+)
+
+var commitStatusPriorities = map[CommitStatusState]int{
+ CommitStatusError: 0,
+ CommitStatusFailure: 1,
+ CommitStatusWarning: 2,
+ CommitStatusPending: 3,
+ CommitStatusSuccess: 4,
+}
+
+func (css CommitStatusState) String() string {
+ return string(css)
+}
+
+// NoBetterThan returns true if this State is no better than the given State
+// This function only handles the states defined in CommitStatusPriorities
+func (css CommitStatusState) NoBetterThan(css2 CommitStatusState) bool {
+ // NoBetterThan only handles the 5 states above
+ if _, exist := commitStatusPriorities[css]; !exist {
+ return false
+ }
+
+ if _, exist := commitStatusPriorities[css2]; !exist {
+ return false
+ }
+
+ return commitStatusPriorities[css] <= commitStatusPriorities[css2]
+}
+
+// IsPending represents if commit status state is pending
+func (css CommitStatusState) IsPending() bool {
+ return css == CommitStatusPending
+}
+
+// IsSuccess represents if commit status state is success
+func (css CommitStatusState) IsSuccess() bool {
+ return css == CommitStatusSuccess
+}
+
+// IsError represents if commit status state is error
+func (css CommitStatusState) IsError() bool {
+ return css == CommitStatusError
+}
+
+// IsFailure represents if commit status state is failure
+func (css CommitStatusState) IsFailure() bool {
+ return css == CommitStatusFailure
+}
+
+// IsWarning represents if commit status state is warning
+func (css CommitStatusState) IsWarning() bool {
+ return css == CommitStatusWarning
+}
diff --git a/modules/structs/commit_status_test.go b/modules/structs/commit_status_test.go
new file mode 100644
index 0000000..f068085
--- /dev/null
+++ b/modules/structs/commit_status_test.go
@@ -0,0 +1,174 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "testing"
+)
+
+func TestNoBetterThan(t *testing.T) {
+ type args struct {
+ css CommitStatusState
+ css2 CommitStatusState
+ }
+ var unExpectedState CommitStatusState
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "success is no better than success",
+ args: args{
+ css: CommitStatusSuccess,
+ css2: CommitStatusSuccess,
+ },
+ want: true,
+ },
+ {
+ name: "success is no better than pending",
+ args: args{
+ css: CommitStatusSuccess,
+ css2: CommitStatusPending,
+ },
+ want: false,
+ },
+ {
+ name: "success is no better than failure",
+ args: args{
+ css: CommitStatusSuccess,
+ css2: CommitStatusFailure,
+ },
+ want: false,
+ },
+ {
+ name: "success is no better than error",
+ args: args{
+ css: CommitStatusSuccess,
+ css2: CommitStatusError,
+ },
+ want: false,
+ },
+ {
+ name: "pending is no better than success",
+ args: args{
+ css: CommitStatusPending,
+ css2: CommitStatusSuccess,
+ },
+ want: true,
+ },
+ {
+ name: "pending is no better than pending",
+ args: args{
+ css: CommitStatusPending,
+ css2: CommitStatusPending,
+ },
+ want: true,
+ },
+ {
+ name: "pending is no better than failure",
+ args: args{
+ css: CommitStatusPending,
+ css2: CommitStatusFailure,
+ },
+ want: false,
+ },
+ {
+ name: "pending is no better than error",
+ args: args{
+ css: CommitStatusPending,
+ css2: CommitStatusError,
+ },
+ want: false,
+ },
+ {
+ name: "failure is no better than success",
+ args: args{
+ css: CommitStatusFailure,
+ css2: CommitStatusSuccess,
+ },
+ want: true,
+ },
+ {
+ name: "failure is no better than pending",
+ args: args{
+ css: CommitStatusFailure,
+ css2: CommitStatusPending,
+ },
+ want: true,
+ },
+ {
+ name: "failure is no better than failure",
+ args: args{
+ css: CommitStatusFailure,
+ css2: CommitStatusFailure,
+ },
+ want: true,
+ },
+ {
+ name: "failure is no better than error",
+ args: args{
+ css: CommitStatusFailure,
+ css2: CommitStatusError,
+ },
+ want: false,
+ },
+ {
+ name: "error is no better than success",
+ args: args{
+ css: CommitStatusError,
+ css2: CommitStatusSuccess,
+ },
+ want: true,
+ },
+ {
+ name: "error is no better than pending",
+ args: args{
+ css: CommitStatusError,
+ css2: CommitStatusPending,
+ },
+ want: true,
+ },
+ {
+ name: "error is no better than failure",
+ args: args{
+ css: CommitStatusError,
+ css2: CommitStatusFailure,
+ },
+ want: true,
+ },
+ {
+ name: "error is no better than error",
+ args: args{
+ css: CommitStatusError,
+ css2: CommitStatusError,
+ },
+ want: true,
+ },
+ {
+ name: "unExpectedState is no better than success",
+ args: args{
+ css: unExpectedState,
+ css2: CommitStatusSuccess,
+ },
+ want: false,
+ },
+ {
+ name: "unExpectedState is no better than unExpectedState",
+ args: args{
+ css: unExpectedState,
+ css2: unExpectedState,
+ },
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.args.css.NoBetterThan(tt.args.css2)
+ if result != tt.want {
+ t.Errorf("NoBetterThan() = %v, want %v", result, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/structs/cron.go b/modules/structs/cron.go
new file mode 100644
index 0000000..39c6a06
--- /dev/null
+++ b/modules/structs/cron.go
@@ -0,0 +1,15 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// Cron represents a Cron task
+type Cron struct {
+ Name string `json:"name"`
+ Schedule string `json:"schedule"`
+ Next time.Time `json:"next"`
+ Prev time.Time `json:"prev"`
+ ExecTimes int64 `json:"exec_times"`
+}
diff --git a/modules/structs/doc.go b/modules/structs/doc.go
new file mode 100644
index 0000000..0db0a25
--- /dev/null
+++ b/modules/structs/doc.go
@@ -0,0 +1,4 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
diff --git a/modules/structs/fork.go b/modules/structs/fork.go
new file mode 100644
index 0000000..eb7774a
--- /dev/null
+++ b/modules/structs/fork.go
@@ -0,0 +1,12 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// CreateForkOption options for creating a fork
+type CreateForkOption struct {
+ // organization name, if forking into an organization
+ Organization *string `json:"organization"`
+ // name of the forked repository
+ Name *string `json:"name"`
+}
diff --git a/modules/structs/git_blob.go b/modules/structs/git_blob.go
new file mode 100644
index 0000000..96c7a27
--- /dev/null
+++ b/modules/structs/git_blob.go
@@ -0,0 +1,13 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// GitBlobResponse represents a git blob
+type GitBlobResponse struct {
+ Content string `json:"content"`
+ Encoding string `json:"encoding"`
+ URL string `json:"url"`
+ SHA string `json:"sha"`
+ Size int64 `json:"size"`
+}
diff --git a/modules/structs/git_hook.go b/modules/structs/git_hook.go
new file mode 100644
index 0000000..2023025
--- /dev/null
+++ b/modules/structs/git_hook.go
@@ -0,0 +1,19 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// GitHook represents a Git repository hook
+type GitHook struct {
+ Name string `json:"name"`
+ IsActive bool `json:"is_active"`
+ Content string `json:"content,omitempty"`
+}
+
+// GitHookList represents a list of Git hooks
+type GitHookList []*GitHook
+
+// EditGitHookOption options when modifying one Git hook
+type EditGitHookOption struct {
+ Content string `json:"content"`
+}
diff --git a/modules/structs/hook.go b/modules/structs/hook.go
new file mode 100644
index 0000000..b7f8861
--- /dev/null
+++ b/modules/structs/hook.go
@@ -0,0 +1,518 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "errors"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+// ErrInvalidReceiveHook FIXME
+var ErrInvalidReceiveHook = errors.New("Invalid JSON payload received over webhook")
+
+// Hook a hook is a web hook when one repository changed
+type Hook struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ BranchFilter string `json:"branch_filter"`
+ URL string `json:"url"`
+
+ // Deprecated: use Metadata instead
+ Config map[string]string `json:"config"`
+ Events []string `json:"events"`
+ AuthorizationHeader string `json:"authorization_header"`
+ ContentType string `json:"content_type"`
+ Metadata any `json:"metadata"`
+ Active bool `json:"active"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+}
+
+// HookList represents a list of API hook.
+type HookList []*Hook
+
+// CreateHookOptionConfig has all config options in it
+// required are "content_type" and "url" Required
+type CreateHookOptionConfig map[string]string
+
+// CreateHookOption options when create a hook
+type CreateHookOption struct {
+ // required: true
+ // enum: ["forgejo", "dingtalk", "discord", "gitea", "gogs", "msteams", "slack", "telegram", "feishu", "wechatwork", "packagist"]
+ Type string `json:"type" binding:"Required"`
+ // required: true
+ Config CreateHookOptionConfig `json:"config" binding:"Required"`
+ Events []string `json:"events"`
+ BranchFilter string `json:"branch_filter" binding:"GlobPattern"`
+ AuthorizationHeader string `json:"authorization_header"`
+ // default: false
+ Active bool `json:"active"`
+}
+
+// EditHookOption options when modify one hook
+type EditHookOption struct {
+ Config map[string]string `json:"config"`
+ Events []string `json:"events"`
+ BranchFilter string `json:"branch_filter" binding:"GlobPattern"`
+ AuthorizationHeader string `json:"authorization_header"`
+ Active *bool `json:"active"`
+}
+
+// Payloader payload is some part of one hook
+type Payloader interface {
+ JSONPayload() ([]byte, error)
+}
+
+// PayloadUser represents the author or committer of a commit
+type PayloadUser struct {
+ // Full name of the commit author
+ Name string `json:"name"`
+ // swagger:strfmt email
+ Email string `json:"email"`
+ UserName string `json:"username"`
+}
+
+// FIXME: consider using same format as API when commits API are added.
+// applies to PayloadCommit and PayloadCommitVerification
+
+// PayloadCommit represents a commit
+type PayloadCommit struct {
+ // sha1 hash of the commit
+ ID string `json:"id"`
+ Message string `json:"message"`
+ URL string `json:"url"`
+ Author *PayloadUser `json:"author"`
+ Committer *PayloadUser `json:"committer"`
+ Verification *PayloadCommitVerification `json:"verification"`
+ // swagger:strfmt date-time
+ Timestamp time.Time `json:"timestamp"`
+ Added []string `json:"added"`
+ Removed []string `json:"removed"`
+ Modified []string `json:"modified"`
+}
+
+// PayloadCommitVerification represents the GPG verification of a commit
+type PayloadCommitVerification struct {
+ Verified bool `json:"verified"`
+ Reason string `json:"reason"`
+ Signature string `json:"signature"`
+ Signer *PayloadUser `json:"signer"`
+ Payload string `json:"payload"`
+}
+
+var (
+ _ Payloader = &CreatePayload{}
+ _ Payloader = &DeletePayload{}
+ _ Payloader = &ForkPayload{}
+ _ Payloader = &PushPayload{}
+ _ Payloader = &IssuePayload{}
+ _ Payloader = &IssueCommentPayload{}
+ _ Payloader = &PullRequestPayload{}
+ _ Payloader = &RepositoryPayload{}
+ _ Payloader = &ReleasePayload{}
+ _ Payloader = &PackagePayload{}
+)
+
+// _________ __
+// \_ ___ \_______ ____ _____ _/ |_ ____
+// / \ \/\_ __ \_/ __ \\__ \\ __\/ __ \
+// \ \____| | \/\ ___/ / __ \| | \ ___/
+// \______ /|__| \___ >____ /__| \___ >
+// \/ \/ \/ \/
+
+// CreatePayload FIXME
+type CreatePayload struct {
+ Sha string `json:"sha"`
+ Ref string `json:"ref"`
+ RefType string `json:"ref_type"`
+ Repo *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload return payload information
+func (p *CreatePayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// ParseCreateHook parses create event hook content.
+func ParseCreateHook(raw []byte) (*CreatePayload, error) {
+ hook := new(CreatePayload)
+ if err := json.Unmarshal(raw, hook); err != nil {
+ return nil, err
+ }
+
+ // it is possible the JSON was parsed, however,
+ // was not from Gogs (maybe was from Bitbucket)
+ // So we'll check to be sure certain key fields
+ // were populated
+ switch {
+ case hook.Repo == nil:
+ return nil, ErrInvalidReceiveHook
+ case len(hook.Ref) == 0:
+ return nil, ErrInvalidReceiveHook
+ }
+ return hook, nil
+}
+
+// ________ .__ __
+// \______ \ ____ | | _____/ |_ ____
+// | | \_/ __ \| | _/ __ \ __\/ __ \
+// | ` \ ___/| |_\ ___/| | \ ___/
+// /_______ /\___ >____/\___ >__| \___ >
+// \/ \/ \/ \/
+
+// PusherType define the type to push
+type PusherType string
+
+// describe all the PusherTypes
+const (
+ PusherTypeUser PusherType = "user"
+)
+
+// DeletePayload represents delete payload
+type DeletePayload struct {
+ Ref string `json:"ref"`
+ RefType string `json:"ref_type"`
+ PusherType PusherType `json:"pusher_type"`
+ Repo *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload implements Payload
+func (p *DeletePayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// ___________ __
+// \_ _____/__________| | __
+// | __)/ _ \_ __ \ |/ /
+// | \( <_> ) | \/ <
+// \___ / \____/|__| |__|_ \
+// \/ \/
+
+// ForkPayload represents fork payload
+type ForkPayload struct {
+ Forkee *Repository `json:"forkee"`
+ Repo *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload implements Payload
+func (p *ForkPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// HookIssueCommentAction defines hook issue comment action
+type HookIssueCommentAction string
+
+// all issue comment actions
+const (
+ HookIssueCommentCreated HookIssueCommentAction = "created"
+ HookIssueCommentEdited HookIssueCommentAction = "edited"
+ HookIssueCommentDeleted HookIssueCommentAction = "deleted"
+)
+
+// IssueCommentPayload represents a payload information of issue comment event.
+type IssueCommentPayload struct {
+ Action HookIssueCommentAction `json:"action"`
+ Issue *Issue `json:"issue"`
+ Comment *Comment `json:"comment"`
+ Changes *ChangesPayload `json:"changes,omitempty"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ IsPull bool `json:"is_pull"`
+}
+
+// JSONPayload implements Payload
+func (p *IssueCommentPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// __________ .__
+// \______ \ ____ | | ____ _____ ______ ____
+// | _// __ \| | _/ __ \\__ \ / ___// __ \
+// | | \ ___/| |_\ ___/ / __ \_\___ \\ ___/
+// |____|_ /\___ >____/\___ >____ /____ >\___ >
+// \/ \/ \/ \/ \/ \/
+
+// HookReleaseAction defines hook release action type
+type HookReleaseAction string
+
+// all release actions
+const (
+ HookReleasePublished HookReleaseAction = "published"
+ HookReleaseUpdated HookReleaseAction = "updated"
+ HookReleaseDeleted HookReleaseAction = "deleted"
+)
+
+// ReleasePayload represents a payload information of release event.
+type ReleasePayload struct {
+ Action HookReleaseAction `json:"action"`
+ Release *Release `json:"release"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload implements Payload
+func (p *ReleasePayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// __________ .__
+// \______ \__ __ _____| |__
+// | ___/ | \/ ___/ | \
+// | | | | /\___ \| Y \
+// |____| |____//____ >___| /
+// \/ \/
+
+// PushPayload represents a payload information of push event.
+type PushPayload struct {
+ Ref string `json:"ref"`
+ Before string `json:"before"`
+ After string `json:"after"`
+ CompareURL string `json:"compare_url"`
+ Commits []*PayloadCommit `json:"commits"`
+ TotalCommits int `json:"total_commits"`
+ HeadCommit *PayloadCommit `json:"head_commit"`
+ Repo *Repository `json:"repository"`
+ Pusher *User `json:"pusher"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload FIXME
+func (p *PushPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// ParsePushHook parses push event hook content.
+func ParsePushHook(raw []byte) (*PushPayload, error) {
+ hook := new(PushPayload)
+ if err := json.Unmarshal(raw, hook); err != nil {
+ return nil, err
+ }
+
+ switch {
+ case hook.Repo == nil:
+ return nil, ErrInvalidReceiveHook
+ case len(hook.Ref) == 0:
+ return nil, ErrInvalidReceiveHook
+ }
+ return hook, nil
+}
+
+// Branch returns branch name from a payload
+func (p *PushPayload) Branch() string {
+ return strings.ReplaceAll(p.Ref, "refs/heads/", "")
+}
+
+// .___
+// | | ______ ________ __ ____
+// | |/ ___// ___/ | \_/ __ \
+// | |\___ \ \___ \| | /\ ___/
+// |___/____ >____ >____/ \___ >
+// \/ \/ \/
+
+// HookIssueAction FIXME
+type HookIssueAction string
+
+const (
+ // HookIssueOpened opened
+ HookIssueOpened HookIssueAction = "opened"
+ // HookIssueClosed closed
+ HookIssueClosed HookIssueAction = "closed"
+ // HookIssueReOpened reopened
+ HookIssueReOpened HookIssueAction = "reopened"
+ // HookIssueEdited edited
+ HookIssueEdited HookIssueAction = "edited"
+ // HookIssueAssigned assigned
+ HookIssueAssigned HookIssueAction = "assigned"
+ // HookIssueUnassigned unassigned
+ HookIssueUnassigned HookIssueAction = "unassigned"
+ // HookIssueLabelUpdated label_updated
+ HookIssueLabelUpdated HookIssueAction = "label_updated"
+ // HookIssueLabelCleared label_cleared
+ HookIssueLabelCleared HookIssueAction = "label_cleared"
+ // HookIssueSynchronized synchronized
+ HookIssueSynchronized HookIssueAction = "synchronized"
+ // HookIssueMilestoned is an issue action for when a milestone is set on an issue.
+ HookIssueMilestoned HookIssueAction = "milestoned"
+ // HookIssueDemilestoned is an issue action for when a milestone is cleared on an issue.
+ HookIssueDemilestoned HookIssueAction = "demilestoned"
+ // HookIssueReviewed is an issue action for when a pull request is reviewed
+ HookIssueReviewed HookIssueAction = "reviewed"
+ // HookIssueReviewRequested is an issue action for when a reviewer is requested for a pull request.
+ HookIssueReviewRequested HookIssueAction = "review_requested"
+ // HookIssueReviewRequestRemoved is an issue action for removing a review request to someone on a pull request.
+ HookIssueReviewRequestRemoved HookIssueAction = "review_request_removed"
+)
+
+// IssuePayload represents the payload information that is sent along with an issue event.
+type IssuePayload struct {
+ Action HookIssueAction `json:"action"`
+ Index int64 `json:"number"`
+ Changes *ChangesPayload `json:"changes,omitempty"`
+ Issue *Issue `json:"issue"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ CommitID string `json:"commit_id"`
+}
+
+// JSONPayload encodes the IssuePayload to JSON, with an indentation of two spaces.
+func (p *IssuePayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// ChangesFromPayload FIXME
+type ChangesFromPayload struct {
+ From string `json:"from"`
+}
+
+// ChangesPayload represents the payload information of issue change
+type ChangesPayload struct {
+ Title *ChangesFromPayload `json:"title,omitempty"`
+ Body *ChangesFromPayload `json:"body,omitempty"`
+ Ref *ChangesFromPayload `json:"ref,omitempty"`
+}
+
+// __________ .__ .__ __________ __
+// \______ \__ __| | | | \______ \ ____ ________ __ ____ _______/ |_
+// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
+// | | | | / |_| |__ | | \ ___< <_| | | /\ ___/ \___ \ | |
+// |____| |____/|____/____/ |____|_ /\___ >__ |____/ \___ >____ > |__|
+// \/ \/ |__| \/ \/
+
+// PullRequestPayload represents a payload information of pull request event.
+type PullRequestPayload struct {
+ Action HookIssueAction `json:"action"`
+ Index int64 `json:"number"`
+ Changes *ChangesPayload `json:"changes,omitempty"`
+ PullRequest *PullRequest `json:"pull_request"`
+ RequestedReviewer *User `json:"requested_reviewer"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ CommitID string `json:"commit_id"`
+ Review *ReviewPayload `json:"review"`
+}
+
+// JSONPayload FIXME
+func (p *PullRequestPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+type HookScheduleAction string
+
+const (
+ HookScheduleCreated HookScheduleAction = "schedule"
+)
+
+type SchedulePayload struct {
+ Action HookScheduleAction `json:"action"`
+}
+
+type WorkflowDispatchPayload struct {
+ Inputs map[string]string `json:"inputs"`
+ Ref string `json:"ref"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ Workflow string `json:"workflow"`
+}
+
+// ReviewPayload FIXME
+type ReviewPayload struct {
+ Type string `json:"type"`
+ Content string `json:"content"`
+}
+
+// __ __.__ __ .__
+// / \ / \__| | _|__|
+// \ \/\/ / | |/ / |
+// \ /| | <| |
+// \__/\ / |__|__|_ \__|
+// \/ \/
+
+// HookWikiAction an action that happens to a wiki page
+type HookWikiAction string
+
+const (
+ // HookWikiCreated created
+ HookWikiCreated HookWikiAction = "created"
+ // HookWikiEdited edited
+ HookWikiEdited HookWikiAction = "edited"
+ // HookWikiDeleted deleted
+ HookWikiDeleted HookWikiAction = "deleted"
+)
+
+// WikiPayload payload for repository webhooks
+type WikiPayload struct {
+ Action HookWikiAction `json:"action"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ Page string `json:"page"`
+ Comment string `json:"comment"`
+}
+
+// JSONPayload JSON representation of the payload
+func (p *WikiPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+//__________ .__ __
+//\______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__.
+// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | |
+// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ |
+// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____|
+// \/ \/|__| \/ \/
+
+// HookRepoAction an action that happens to a repo
+type HookRepoAction string
+
+const (
+ // HookRepoCreated created
+ HookRepoCreated HookRepoAction = "created"
+ // HookRepoDeleted deleted
+ HookRepoDeleted HookRepoAction = "deleted"
+)
+
+// RepositoryPayload payload for repository webhooks
+type RepositoryPayload struct {
+ Action HookRepoAction `json:"action"`
+ Repository *Repository `json:"repository"`
+ Organization *User `json:"organization"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload JSON representation of the payload
+func (p *RepositoryPayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
+
+// HookPackageAction an action that happens to a package
+type HookPackageAction string
+
+const (
+ // HookPackageCreated created
+ HookPackageCreated HookPackageAction = "created"
+ // HookPackageDeleted deleted
+ HookPackageDeleted HookPackageAction = "deleted"
+)
+
+// PackagePayload represents a package payload
+type PackagePayload struct {
+ Action HookPackageAction `json:"action"`
+ Repository *Repository `json:"repository"`
+ Package *Package `json:"package"`
+ Organization *User `json:"organization"`
+ Sender *User `json:"sender"`
+}
+
+// JSONPayload implements Payload
+func (p *PackagePayload) JSONPayload() ([]byte, error) {
+ return json.MarshalIndent(p, "", " ")
+}
diff --git a/modules/structs/issue.go b/modules/structs/issue.go
new file mode 100644
index 0000000..a67bdcf
--- /dev/null
+++ b/modules/structs/issue.go
@@ -0,0 +1,269 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "fmt"
+ "path"
+ "slices"
+ "strings"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+// StateType issue state type
+type StateType string
+
+const (
+ // StateOpen pr is opend
+ StateOpen StateType = "open"
+ // StateClosed pr is closed
+ StateClosed StateType = "closed"
+ // StateAll is all
+ StateAll StateType = "all"
+)
+
+// PullRequestMeta PR info if an issue is a PR
+type PullRequestMeta struct {
+ HasMerged bool `json:"merged"`
+ Merged *time.Time `json:"merged_at"`
+ IsWorkInProgress bool `json:"draft"`
+ HTMLURL string `json:"html_url"`
+}
+
+// RepositoryMeta basic repository information
+type RepositoryMeta struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ FullName string `json:"full_name"`
+}
+
+// Issue represents an issue in a repository
+// swagger:model
+type Issue struct {
+ ID int64 `json:"id"`
+ URL string `json:"url"`
+ HTMLURL string `json:"html_url"`
+ Index int64 `json:"number"`
+ Poster *User `json:"user"`
+ OriginalAuthor string `json:"original_author"`
+ OriginalAuthorID int64 `json:"original_author_id"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Ref string `json:"ref"`
+ Attachments []*Attachment `json:"assets"`
+ Labels []*Label `json:"labels"`
+ Milestone *Milestone `json:"milestone"`
+ // deprecated
+ Assignee *User `json:"assignee"`
+ Assignees []*User `json:"assignees"`
+ // Whether the issue is open or closed
+ //
+ // type: string
+ // enum: ["open", "closed"]
+ State StateType `json:"state"`
+ IsLocked bool `json:"is_locked"`
+ Comments int `json:"comments"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+ // swagger:strfmt date-time
+ Closed *time.Time `json:"closed_at"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+
+ PullRequest *PullRequestMeta `json:"pull_request"`
+ Repo *RepositoryMeta `json:"repository"`
+
+ PinOrder int `json:"pin_order"`
+}
+
+// CreateIssueOption options to create one issue
+type CreateIssueOption struct {
+ // required:true
+ Title string `json:"title" binding:"Required"`
+ Body string `json:"body"`
+ Ref string `json:"ref"`
+ // deprecated
+ Assignee string `json:"assignee"`
+ Assignees []string `json:"assignees"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+ // milestone id
+ Milestone int64 `json:"milestone"`
+ // list of label ids
+ Labels []int64 `json:"labels"`
+ Closed bool `json:"closed"`
+}
+
+// EditIssueOption options for editing an issue
+type EditIssueOption struct {
+ Title string `json:"title"`
+ Body *string `json:"body"`
+ Ref *string `json:"ref"`
+ // deprecated
+ Assignee *string `json:"assignee"`
+ Assignees []string `json:"assignees"`
+ Milestone *int64 `json:"milestone"`
+ State *string `json:"state"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+ RemoveDeadline *bool `json:"unset_due_date"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+}
+
+// EditDeadlineOption options for creating a deadline
+type EditDeadlineOption struct {
+ // required:true
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+}
+
+// IssueDeadline represents an issue deadline
+// swagger:model
+type IssueDeadline struct {
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+}
+
+// IssueFormFieldType defines issue form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes"
+type IssueFormFieldType string
+
+const (
+ IssueFormFieldTypeMarkdown IssueFormFieldType = "markdown"
+ IssueFormFieldTypeTextarea IssueFormFieldType = "textarea"
+ IssueFormFieldTypeInput IssueFormFieldType = "input"
+ IssueFormFieldTypeDropdown IssueFormFieldType = "dropdown"
+ IssueFormFieldTypeCheckboxes IssueFormFieldType = "checkboxes"
+)
+
+// IssueFormField represents a form field
+// swagger:model
+type IssueFormField struct {
+ Type IssueFormFieldType `json:"type" yaml:"type"`
+ ID string `json:"id" yaml:"id"`
+ Attributes map[string]any `json:"attributes" yaml:"attributes"`
+ Validations map[string]any `json:"validations" yaml:"validations"`
+ Visible []IssueFormFieldVisible `json:"visible,omitempty"`
+}
+
+func (iff IssueFormField) VisibleOnForm() bool {
+ if len(iff.Visible) == 0 {
+ return true
+ }
+ return slices.Contains(iff.Visible, IssueFormFieldVisibleForm)
+}
+
+func (iff IssueFormField) VisibleInContent() bool {
+ if len(iff.Visible) == 0 {
+ // we have our markdown exception
+ return iff.Type != IssueFormFieldTypeMarkdown
+ }
+ return slices.Contains(iff.Visible, IssueFormFieldVisibleContent)
+}
+
+// IssueFormFieldVisible defines issue form field visible
+// swagger:model
+type IssueFormFieldVisible string
+
+const (
+ IssueFormFieldVisibleForm IssueFormFieldVisible = "form"
+ IssueFormFieldVisibleContent IssueFormFieldVisible = "content"
+)
+
+// IssueTemplate represents an issue template for a repository
+// swagger:model
+type IssueTemplate struct {
+ Name string `json:"name" yaml:"name"`
+ Title string `json:"title" yaml:"title"`
+ About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
+ Labels IssueTemplateLabels `json:"labels" yaml:"labels"`
+ Ref string `json:"ref" yaml:"ref"`
+ Content string `json:"content" yaml:"-"`
+ Fields []*IssueFormField `json:"body" yaml:"body"`
+ FileName string `json:"file_name" yaml:"-"`
+}
+
+type IssueTemplateLabels []string
+
+func (l *IssueTemplateLabels) UnmarshalYAML(value *yaml.Node) error {
+ var labels []string
+ if value.IsZero() {
+ *l = labels
+ return nil
+ }
+ switch value.Kind {
+ case yaml.ScalarNode:
+ str := ""
+ err := value.Decode(&str)
+ if err != nil {
+ return err
+ }
+ for _, v := range strings.Split(str, ",") {
+ if v = strings.TrimSpace(v); v == "" {
+ continue
+ }
+ labels = append(labels, v)
+ }
+ *l = labels
+ return nil
+ case yaml.SequenceNode:
+ if err := value.Decode(&labels); err != nil {
+ return err
+ }
+ *l = labels
+ return nil
+ }
+ return fmt.Errorf("line %d: cannot unmarshal %s into IssueTemplateLabels", value.Line, value.ShortTag())
+}
+
+type IssueConfigContactLink struct {
+ Name string `json:"name" yaml:"name"`
+ URL string `json:"url" yaml:"url"`
+ About string `json:"about" yaml:"about"`
+}
+
+type IssueConfig struct {
+ BlankIssuesEnabled bool `json:"blank_issues_enabled" yaml:"blank_issues_enabled"`
+ ContactLinks []IssueConfigContactLink `json:"contact_links" yaml:"contact_links"`
+}
+
+type IssueConfigValidation struct {
+ Valid bool `json:"valid"`
+ Message string `json:"message"`
+}
+
+// IssueTemplateType defines issue template type
+type IssueTemplateType string
+
+const (
+ IssueTemplateTypeMarkdown IssueTemplateType = "md"
+ IssueTemplateTypeYaml IssueTemplateType = "yaml"
+)
+
+// Type returns the type of IssueTemplate, can be "md", "yaml" or empty for known
+func (it IssueTemplate) Type() IssueTemplateType {
+ if base := path.Base(it.FileName); base == "config.yaml" || base == "config.yml" {
+ // ignore config.yaml which is a special configuration file
+ return ""
+ }
+ if ext := path.Ext(it.FileName); ext == ".md" {
+ return IssueTemplateTypeMarkdown
+ } else if ext == ".yaml" || ext == ".yml" {
+ return IssueTemplateTypeYaml
+ }
+ return ""
+}
+
+// IssueMeta basic issue information
+// swagger:model
+type IssueMeta struct {
+ Index int64 `json:"index"`
+ Owner string `json:"owner"`
+ Name string `json:"repo"`
+}
diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go
new file mode 100644
index 0000000..9ecb4a1
--- /dev/null
+++ b/modules/structs/issue_comment.go
@@ -0,0 +1,86 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Comment represents a comment on a commit or issue
+type Comment struct {
+ ID int64 `json:"id"`
+ HTMLURL string `json:"html_url"`
+ PRURL string `json:"pull_request_url"`
+ IssueURL string `json:"issue_url"`
+ Poster *User `json:"user"`
+ OriginalAuthor string `json:"original_author"`
+ OriginalAuthorID int64 `json:"original_author_id"`
+ Body string `json:"body"`
+ Attachments []*Attachment `json:"assets"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+}
+
+// CreateIssueCommentOption options for creating a comment on an issue
+type CreateIssueCommentOption struct {
+ // required:true
+ Body string `json:"body" binding:"Required"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+}
+
+// EditIssueCommentOption options for editing a comment
+type EditIssueCommentOption struct {
+ // required: true
+ Body string `json:"body" binding:"Required"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+}
+
+// TimelineComment represents a timeline comment (comment of any type) on a commit or issue
+type TimelineComment struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+
+ HTMLURL string `json:"html_url"`
+ PRURL string `json:"pull_request_url"`
+ IssueURL string `json:"issue_url"`
+ Poster *User `json:"user"`
+ Body string `json:"body"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+
+ OldProjectID int64 `json:"old_project_id"`
+ ProjectID int64 `json:"project_id"`
+ OldMilestone *Milestone `json:"old_milestone"`
+ Milestone *Milestone `json:"milestone"`
+ TrackedTime *TrackedTime `json:"tracked_time"`
+ OldTitle string `json:"old_title"`
+ NewTitle string `json:"new_title"`
+ OldRef string `json:"old_ref"`
+ NewRef string `json:"new_ref"`
+
+ RefIssue *Issue `json:"ref_issue"`
+ RefComment *Comment `json:"ref_comment"`
+ RefAction string `json:"ref_action"`
+ // commit SHA where issue/PR was referenced
+ RefCommitSHA string `json:"ref_commit_sha"`
+
+ ReviewID int64 `json:"review_id"`
+
+ Label *Label `json:"label"`
+
+ Assignee *User `json:"assignee"`
+ AssigneeTeam *Team `json:"assignee_team"`
+ // whether the assignees were removed or added
+ RemovedAssignee bool `json:"removed_assignee"`
+
+ ResolveDoer *User `json:"resolve_doer"`
+
+ DependentIssue *Issue `json:"dependent_issue"`
+}
diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go
new file mode 100644
index 0000000..153c412
--- /dev/null
+++ b/modules/structs/issue_label.go
@@ -0,0 +1,75 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Label a label to an issue or a pr
+// swagger:model
+type Label struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ // example: false
+ Exclusive bool `json:"exclusive"`
+ // example: false
+ IsArchived bool `json:"is_archived"`
+ // example: 00aabb
+ Color string `json:"color"`
+ Description string `json:"description"`
+ URL string `json:"url"`
+}
+
+// CreateLabelOption options for creating a label
+type CreateLabelOption struct {
+ // required:true
+ Name string `json:"name" binding:"Required"`
+ // example: false
+ Exclusive bool `json:"exclusive"`
+ // required:true
+ // example: #00aabb
+ Color string `json:"color" binding:"Required"`
+ Description string `json:"description"`
+ // example: false
+ IsArchived bool `json:"is_archived"`
+}
+
+// EditLabelOption options for editing a label
+type EditLabelOption struct {
+ Name *string `json:"name"`
+ // example: false
+ Exclusive *bool `json:"exclusive"`
+ // example: #00aabb
+ Color *string `json:"color"`
+ Description *string `json:"description"`
+ // example: false
+ IsArchived *bool `json:"is_archived"`
+}
+
+// DeleteLabelOption options for deleting a label
+type DeleteLabelsOption struct {
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+}
+
+// IssueLabelsOption a collection of labels
+type IssueLabelsOption struct {
+ // Labels can be a list of integers representing label IDs
+ // or a list of strings representing label names
+ Labels []any `json:"labels"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+}
+
+// LabelTemplate info of a Label template
+type LabelTemplate struct {
+ Name string `json:"name"`
+ // example: false
+ Exclusive bool `json:"exclusive"`
+ // example: 00aabb
+ Color string `json:"color"`
+ Description string `json:"description"`
+}
diff --git a/modules/structs/issue_milestone.go b/modules/structs/issue_milestone.go
new file mode 100644
index 0000000..0518244
--- /dev/null
+++ b/modules/structs/issue_milestone.go
@@ -0,0 +1,44 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Milestone milestone is a collection of issues on one repository
+type Milestone struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State StateType `json:"state"`
+ OpenIssues int `json:"open_issues"`
+ ClosedIssues int `json:"closed_issues"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+ // swagger:strfmt date-time
+ Closed *time.Time `json:"closed_at"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_on"`
+}
+
+// CreateMilestoneOption options for creating a milestone
+type CreateMilestoneOption struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_on"`
+ // enum: ["open", "closed"]
+ State string `json:"state"`
+}
+
+// EditMilestoneOption options for editing a milestone
+type EditMilestoneOption struct {
+ Title string `json:"title"`
+ Description *string `json:"description"`
+ State *string `json:"state"`
+ Deadline *time.Time `json:"due_on"`
+}
diff --git a/modules/structs/issue_reaction.go b/modules/structs/issue_reaction.go
new file mode 100644
index 0000000..8d907a4
--- /dev/null
+++ b/modules/structs/issue_reaction.go
@@ -0,0 +1,21 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// EditReactionOption contain the reaction type
+type EditReactionOption struct {
+ Reaction string `json:"content"`
+}
+
+// Reaction contain one reaction
+type Reaction struct {
+ User *User `json:"user"`
+ Reaction string `json:"content"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+}
diff --git a/modules/structs/issue_stopwatch.go b/modules/structs/issue_stopwatch.go
new file mode 100644
index 0000000..ceade1d
--- /dev/null
+++ b/modules/structs/issue_stopwatch.go
@@ -0,0 +1,23 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// StopWatch represent a running stopwatch
+type StopWatch struct {
+ // swagger:strfmt date-time
+ Created time.Time `json:"created"`
+ Seconds int64 `json:"seconds"`
+ Duration string `json:"duration"`
+ IssueIndex int64 `json:"issue_index"`
+ IssueTitle string `json:"issue_title"`
+ RepoOwnerName string `json:"repo_owner_name"`
+ RepoName string `json:"repo_name"`
+}
+
+// StopWatches represent a list of stopwatches
+type StopWatches []StopWatch
diff --git a/modules/structs/issue_test.go b/modules/structs/issue_test.go
new file mode 100644
index 0000000..2003e22
--- /dev/null
+++ b/modules/structs/issue_test.go
@@ -0,0 +1,106 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestIssueTemplate_Type(t *testing.T) {
+ tests := []struct {
+ fileName string
+ want IssueTemplateType
+ }{
+ {
+ fileName: ".gitea/ISSUE_TEMPLATE/bug_report.yaml",
+ want: IssueTemplateTypeYaml,
+ },
+ {
+ fileName: ".gitea/ISSUE_TEMPLATE/bug_report.md",
+ want: IssueTemplateTypeMarkdown,
+ },
+ {
+ fileName: ".gitea/ISSUE_TEMPLATE/bug_report.txt",
+ want: "",
+ },
+ {
+ fileName: ".gitea/ISSUE_TEMPLATE/config.yaml",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.fileName, func(t *testing.T) {
+ it := IssueTemplate{
+ FileName: tt.fileName,
+ }
+ assert.Equal(t, tt.want, it.Type())
+ })
+ }
+}
+
+func TestIssueTemplateLabels_UnmarshalYAML(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ tmpl *IssueTemplate
+ want *IssueTemplate
+ wantErr string
+ }{
+ {
+ name: "array",
+ content: `labels: ["a", "b", "c"]`,
+ tmpl: &IssueTemplate{
+ Labels: []string{"should_be_overwrote"},
+ },
+ want: &IssueTemplate{
+ Labels: []string{"a", "b", "c"},
+ },
+ },
+ {
+ name: "string",
+ content: `labels: "a,b,c"`,
+ tmpl: &IssueTemplate{
+ Labels: []string{"should_be_overwrote"},
+ },
+ want: &IssueTemplate{
+ Labels: []string{"a", "b", "c"},
+ },
+ },
+ {
+ name: "empty",
+ content: `labels:`,
+ tmpl: &IssueTemplate{
+ Labels: []string{"should_be_overwrote"},
+ },
+ want: &IssueTemplate{
+ Labels: nil,
+ },
+ },
+ {
+ name: "error",
+ content: `
+labels:
+ a: aa
+ b: bb
+`,
+ tmpl: &IssueTemplate{},
+ wantErr: "line 3: cannot unmarshal !!map into IssueTemplateLabels",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := yaml.Unmarshal([]byte(tt.content), tt.tmpl)
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, tt.tmpl)
+ }
+ })
+ }
+}
diff --git a/modules/structs/issue_tracked_time.go b/modules/structs/issue_tracked_time.go
new file mode 100644
index 0000000..a3904af
--- /dev/null
+++ b/modules/structs/issue_tracked_time.go
@@ -0,0 +1,37 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// AddTimeOption options for adding time to an issue
+type AddTimeOption struct {
+ // time in seconds
+ // required: true
+ Time int64 `json:"time" binding:"Required"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created"`
+ // User who spent the time (optional)
+ User string `json:"user_name"`
+}
+
+// TrackedTime worked time for an issue / pr
+type TrackedTime struct {
+ ID int64 `json:"id"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created"`
+ // Time in seconds
+ Time int64 `json:"time"`
+ // deprecated (only for backwards compatibility)
+ UserID int64 `json:"user_id"`
+ UserName string `json:"user_name"`
+ // deprecated (only for backwards compatibility)
+ IssueID int64 `json:"issue_id"`
+ Issue *Issue `json:"issue"`
+}
+
+// TrackedTimeList represents a list of tracked times
+type TrackedTimeList []*TrackedTime
diff --git a/modules/structs/lfs_lock.go b/modules/structs/lfs_lock.go
new file mode 100644
index 0000000..6b4c0bc
--- /dev/null
+++ b/modules/structs/lfs_lock.go
@@ -0,0 +1,64 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// LFSLock represent a lock
+// for use with the locks API.
+type LFSLock struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ LockedAt time.Time `json:"locked_at"`
+ Owner *LFSLockOwner `json:"owner"`
+}
+
+// LFSLockOwner represent a lock owner
+// for use with the locks API.
+type LFSLockOwner struct {
+ Name string `json:"name"`
+}
+
+// LFSLockRequest contains the path of the lock to create
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/locking.md#create-lock
+type LFSLockRequest struct {
+ Path string `json:"path"`
+}
+
+// LFSLockResponse represent a lock created
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/locking.md#create-lock
+type LFSLockResponse struct {
+ Lock *LFSLock `json:"lock"`
+}
+
+// LFSLockList represent a list of lock requested
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/locking.md#list-locks
+type LFSLockList struct {
+ Locks []*LFSLock `json:"locks"`
+ Next string `json:"next_cursor,omitempty"`
+}
+
+// LFSLockListVerify represent a list of lock verification requested
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/locking.md#list-locks-for-verification
+type LFSLockListVerify struct {
+ Ours []*LFSLock `json:"ours"`
+ Theirs []*LFSLock `json:"theirs"`
+ Next string `json:"next_cursor,omitempty"`
+}
+
+// LFSLockError contains information on the error that occurs
+type LFSLockError struct {
+ Message string `json:"message"`
+ Lock *LFSLock `json:"lock,omitempty"`
+ Documentation string `json:"documentation_url,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
+}
+
+// LFSLockDeleteRequest contains params of a delete request
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/locking.md#delete-lock
+type LFSLockDeleteRequest struct {
+ Force bool `json:"force"`
+}
diff --git a/modules/structs/mirror.go b/modules/structs/mirror.go
new file mode 100644
index 0000000..1b65668
--- /dev/null
+++ b/modules/structs/mirror.go
@@ -0,0 +1,32 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// CreatePushMirrorOption represents need information to create a push mirror of a repository.
+type CreatePushMirrorOption struct {
+ RemoteAddress string `json:"remote_address"`
+ RemoteUsername string `json:"remote_username"`
+ RemotePassword string `json:"remote_password"`
+ Interval string `json:"interval"`
+ SyncOnCommit bool `json:"sync_on_commit"`
+ UseSSH bool `json:"use_ssh"`
+}
+
+// PushMirror represents information of a push mirror
+// swagger:model
+type PushMirror struct {
+ RepoName string `json:"repo_name"`
+ RemoteName string `json:"remote_name"`
+ RemoteAddress string `json:"remote_address"`
+ // swagger:strfmt date-time
+ CreatedUnix time.Time `json:"created"`
+ // swagger:strfmt date-time
+ LastUpdateUnix *time.Time `json:"last_update"`
+ LastError string `json:"last_error"`
+ Interval string `json:"interval"`
+ SyncOnCommit bool `json:"sync_on_commit"`
+ PublicKey string `json:"public_key"`
+}
diff --git a/modules/structs/miscellaneous.go b/modules/structs/miscellaneous.go
new file mode 100644
index 0000000..bff10f9
--- /dev/null
+++ b/modules/structs/miscellaneous.go
@@ -0,0 +1,101 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// SearchResults results of a successful search
+type SearchResults struct {
+ OK bool `json:"ok"`
+ Data []*Repository `json:"data"`
+}
+
+// SearchError error of a failed search
+type SearchError struct {
+ OK bool `json:"ok"`
+ Error string `json:"error"`
+}
+
+// MarkupOption markup options
+type MarkupOption struct {
+ // Text markup to render
+ //
+ // in: body
+ Text string
+ // Mode to render (comment, gfm, markdown, file)
+ //
+ // in: body
+ Mode string
+ // Context to render
+ //
+ // in: body
+ Context string
+ // Is it a wiki page ?
+ //
+ // in: body
+ Wiki bool
+ // File path for detecting extension in file mode
+ //
+ // in: body
+ FilePath string
+}
+
+// MarkupRender is a rendered markup document
+// swagger:response MarkupRender
+type MarkupRender string
+
+// MarkdownOption markdown options
+type MarkdownOption struct {
+ // Text markdown to render
+ //
+ // in: body
+ Text string
+ // Mode to render (comment, gfm, markdown)
+ //
+ // in: body
+ Mode string
+ // Context to render
+ //
+ // in: body
+ Context string
+ // Is it a wiki page ?
+ //
+ // in: body
+ Wiki bool
+}
+
+// MarkdownRender is a rendered markdown document
+// swagger:response MarkdownRender
+type MarkdownRender string
+
+// ServerVersion wraps the version of the server
+type ServerVersion struct {
+ Version string `json:"version"`
+}
+
+// GitignoreTemplateInfo name and text of a gitignore template
+type GitignoreTemplateInfo struct {
+ Name string `json:"name"`
+ Source string `json:"source"`
+}
+
+// LicensesListEntry is used for the API
+type LicensesTemplateListEntry struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+}
+
+// LicensesInfo contains information about a License
+type LicenseTemplateInfo struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Implementation string `json:"implementation"`
+ Body string `json:"body"`
+}
+
+// APIError is an api error with a message
+type APIError struct {
+ Message string `json:"message"`
+ URL string `json:"url"`
+}
diff --git a/modules/structs/moderation.go b/modules/structs/moderation.go
new file mode 100644
index 0000000..c1e5508
--- /dev/null
+++ b/modules/structs/moderation.go
@@ -0,0 +1,13 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// BlockedUser represents a blocked user.
+type BlockedUser struct {
+ BlockID int64 `json:"block_id"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+}
diff --git a/modules/structs/nodeinfo.go b/modules/structs/nodeinfo.go
new file mode 100644
index 0000000..802c8d3
--- /dev/null
+++ b/modules/structs/nodeinfo.go
@@ -0,0 +1,43 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks
+type NodeInfo struct {
+ Version string `json:"version"`
+ Software NodeInfoSoftware `json:"software"`
+ Protocols []string `json:"protocols"`
+ Services NodeInfoServices `json:"services"`
+ OpenRegistrations bool `json:"openRegistrations"`
+ Usage NodeInfoUsage `json:"usage"`
+ Metadata struct{} `json:"metadata"`
+}
+
+// NodeInfoSoftware contains Metadata about server software in use
+type NodeInfoSoftware struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Repository string `json:"repository"`
+ Homepage string `json:"homepage"`
+}
+
+// NodeInfoServices contains the third party sites this server can connect to via their application API
+type NodeInfoServices struct {
+ Inbound []string `json:"inbound"`
+ Outbound []string `json:"outbound"`
+}
+
+// NodeInfoUsage contains usage statistics for this server
+type NodeInfoUsage struct {
+ Users NodeInfoUsageUsers `json:"users"`
+ LocalPosts int `json:"localPosts,omitempty"`
+ LocalComments int `json:"localComments,omitempty"`
+}
+
+// NodeInfoUsageUsers contains statistics about the users of this server
+type NodeInfoUsageUsers struct {
+ Total int `json:"total,omitempty"`
+ ActiveHalfyear int `json:"activeHalfyear,omitempty"`
+ ActiveMonth int `json:"activeMonth,omitempty"`
+}
diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go
new file mode 100644
index 0000000..7fbf4cb
--- /dev/null
+++ b/modules/structs/notifications.go
@@ -0,0 +1,49 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// NotificationThread expose Notification on API
+type NotificationThread struct {
+ ID int64 `json:"id"`
+ Repository *Repository `json:"repository"`
+ Subject *NotificationSubject `json:"subject"`
+ Unread bool `json:"unread"`
+ Pinned bool `json:"pinned"`
+ UpdatedAt time.Time `json:"updated_at"`
+ URL string `json:"url"`
+}
+
+// NotificationSubject contains the notification subject (Issue/Pull/Commit)
+type NotificationSubject struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ LatestCommentURL string `json:"latest_comment_url"`
+ HTMLURL string `json:"html_url"`
+ LatestCommentHTMLURL string `json:"latest_comment_html_url"`
+ Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository)"`
+ State StateType `json:"state"`
+}
+
+// NotificationCount number of unread notifications
+type NotificationCount struct {
+ New int64 `json:"new"`
+}
+
+// NotifySubjectType represent type of notification subject
+type NotifySubjectType string
+
+const (
+ // NotifySubjectIssue an issue is subject of an notification
+ NotifySubjectIssue NotifySubjectType = "Issue"
+ // NotifySubjectPull an pull is subject of an notification
+ NotifySubjectPull NotifySubjectType = "Pull"
+ // NotifySubjectCommit an commit is subject of an notification
+ NotifySubjectCommit NotifySubjectType = "Commit"
+ // NotifySubjectRepository an repository is subject of an notification
+ NotifySubjectRepository NotifySubjectType = "Repository"
+)
diff --git a/modules/structs/org.go b/modules/structs/org.go
new file mode 100644
index 0000000..b2b2c61
--- /dev/null
+++ b/modules/structs/org.go
@@ -0,0 +1,59 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Organization represents an organization
+type Organization struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Email string `json:"email"`
+ AvatarURL string `json:"avatar_url"`
+ Description string `json:"description"`
+ Website string `json:"website"`
+ Location string `json:"location"`
+ Visibility string `json:"visibility"`
+ RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
+ // deprecated
+ UserName string `json:"username"`
+}
+
+// OrganizationPermissions list different users permissions on an organization
+type OrganizationPermissions struct {
+ IsOwner bool `json:"is_owner"`
+ IsAdmin bool `json:"is_admin"`
+ CanWrite bool `json:"can_write"`
+ CanRead bool `json:"can_read"`
+ CanCreateRepository bool `json:"can_create_repository"`
+}
+
+// CreateOrgOption options for creating an organization
+type CreateOrgOption struct {
+ // required: true
+ UserName string `json:"username" binding:"Required;Username;MaxSize(40)"`
+ FullName string `json:"full_name" binding:"MaxSize(100)"`
+ Email string `json:"email" binding:"MaxSize(255)"`
+ Description string `json:"description" binding:"MaxSize(255)"`
+ Website string `json:"website" binding:"ValidUrl;MaxSize(255)"`
+ Location string `json:"location" binding:"MaxSize(50)"`
+ // possible values are `public` (default), `limited` or `private`
+ // enum: ["public", "limited", "private"]
+ Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
+ RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
+}
+
+// TODO: make EditOrgOption fields optional after https://gitea.com/go-chi/binding/pulls/5 got merged
+
+// EditOrgOption options for editing an organization
+type EditOrgOption struct {
+ FullName string `json:"full_name" binding:"MaxSize(100)"`
+ Email string `json:"email" binding:"MaxSize(255)"`
+ Description string `json:"description" binding:"MaxSize(255)"`
+ Website string `json:"website" binding:"ValidUrl;MaxSize(255)"`
+ Location string `json:"location" binding:"MaxSize(50)"`
+ // possible values are `public`, `limited` or `private`
+ // enum: ["public", "limited", "private"]
+ Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
+ RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`
+}
diff --git a/modules/structs/org_member.go b/modules/structs/org_member.go
new file mode 100644
index 0000000..2df5099
--- /dev/null
+++ b/modules/structs/org_member.go
@@ -0,0 +1,9 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// AddOrgMembershipOption add user to organization options
+type AddOrgMembershipOption struct {
+ Role string `json:"role" binding:"Required"`
+}
diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go
new file mode 100644
index 0000000..4417758
--- /dev/null
+++ b/modules/structs/org_team.go
@@ -0,0 +1,54 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Team represents a team in an organization
+type Team struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Organization *Organization `json:"organization"`
+ IncludesAllRepositories bool `json:"includes_all_repositories"`
+ // enum: ["none", "read", "write", "admin", "owner"]
+ Permission string `json:"permission"`
+ // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
+ // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
+ Units []string `json:"units"`
+ // example: {"repo.code":"read","repo.issues":"write","repo.ext_issues":"none","repo.wiki":"admin","repo.pulls":"owner","repo.releases":"none","repo.projects":"none","repo.ext_wiki":"none"}
+ UnitsMap map[string]string `json:"units_map"`
+ CanCreateOrgRepo bool `json:"can_create_org_repo"`
+}
+
+// CreateTeamOption options for creating a team
+type CreateTeamOption struct {
+ // required: true
+ Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(255)"`
+ Description string `json:"description" binding:"MaxSize(255)"`
+ IncludesAllRepositories bool `json:"includes_all_repositories"`
+ // enum: ["read", "write", "admin"]
+ Permission string `json:"permission"`
+ // example: ["repo.actions","repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.ext_wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
+ // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
+ Units []string `json:"units"`
+ // example: {"repo.actions","repo.packages","repo.code":"read","repo.issues":"write","repo.ext_issues":"none","repo.wiki":"admin","repo.pulls":"owner","repo.releases":"none","repo.projects":"none","repo.ext_wiki":"none"}
+ UnitsMap map[string]string `json:"units_map"`
+ CanCreateOrgRepo bool `json:"can_create_org_repo"`
+}
+
+// EditTeamOption options for editing a team
+type EditTeamOption struct {
+ // required: true
+ Name string `json:"name" binding:"AlphaDashDot;MaxSize(255)"`
+ Description *string `json:"description" binding:"MaxSize(255)"`
+ IncludesAllRepositories *bool `json:"includes_all_repositories"`
+ // enum: ["read", "write", "admin"]
+ Permission string `json:"permission"`
+ // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
+ // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
+ Units []string `json:"units"`
+ // example: {"repo.code":"read","repo.issues":"write","repo.ext_issues":"none","repo.wiki":"admin","repo.pulls":"owner","repo.releases":"none","repo.projects":"none","repo.ext_wiki":"none"}
+ UnitsMap map[string]string `json:"units_map"`
+ CanCreateOrgRepo *bool `json:"can_create_org_repo"`
+}
diff --git a/modules/structs/package.go b/modules/structs/package.go
new file mode 100644
index 0000000..a9a9429
--- /dev/null
+++ b/modules/structs/package.go
@@ -0,0 +1,33 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Package represents a package
+type Package struct {
+ ID int64 `json:"id"`
+ Owner *User `json:"owner"`
+ Repository *Repository `json:"repository"`
+ Creator *User `json:"creator"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ HTMLURL string `json:"html_url"`
+ // swagger:strfmt date-time
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PackageFile represents a package file
+type PackageFile struct {
+ ID int64 `json:"id"`
+ Size int64
+ Name string `json:"name"`
+ HashMD5 string `json:"md5"`
+ HashSHA1 string `json:"sha1"`
+ HashSHA256 string `json:"sha256"`
+ HashSHA512 string `json:"sha512"`
+}
diff --git a/modules/structs/pull.go b/modules/structs/pull.go
new file mode 100644
index 0000000..ab62766
--- /dev/null
+++ b/modules/structs/pull.go
@@ -0,0 +1,119 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// PullRequest represents a pull request
+type PullRequest struct {
+ ID int64 `json:"id"`
+ URL string `json:"url"`
+ Index int64 `json:"number"`
+ Poster *User `json:"user"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Labels []*Label `json:"labels"`
+ Milestone *Milestone `json:"milestone"`
+ Assignee *User `json:"assignee"`
+ Assignees []*User `json:"assignees"`
+ RequestedReviewers []*User `json:"requested_reviewers"`
+ RequestedReviewersTeams []*Team `json:"requested_reviewers_teams"`
+ State StateType `json:"state"`
+ Draft bool `json:"draft"`
+ IsLocked bool `json:"is_locked"`
+ Comments int `json:"comments"`
+ // number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
+ ReviewComments int `json:"review_comments"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ ChangedFiles int `json:"changed_files"`
+
+ HTMLURL string `json:"html_url"`
+ DiffURL string `json:"diff_url"`
+ PatchURL string `json:"patch_url"`
+
+ Mergeable bool `json:"mergeable"`
+ HasMerged bool `json:"merged"`
+ // swagger:strfmt date-time
+ Merged *time.Time `json:"merged_at"`
+ MergedCommitID *string `json:"merge_commit_sha"`
+ MergedBy *User `json:"merged_by"`
+ AllowMaintainerEdit bool `json:"allow_maintainer_edit"`
+
+ Base *PRBranchInfo `json:"base"`
+ Head *PRBranchInfo `json:"head"`
+ MergeBase string `json:"merge_base"`
+
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+
+ // swagger:strfmt date-time
+ Created *time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated *time.Time `json:"updated_at"`
+ // swagger:strfmt date-time
+ Closed *time.Time `json:"closed_at"`
+
+ PinOrder int `json:"pin_order"`
+}
+
+// PRBranchInfo information about a branch
+type PRBranchInfo struct {
+ Name string `json:"label"`
+ Ref string `json:"ref"`
+ Sha string `json:"sha"`
+ RepoID int64 `json:"repo_id"`
+ Repository *Repository `json:"repo"`
+}
+
+// ListPullRequestsOptions options for listing pull requests
+type ListPullRequestsOptions struct {
+ Page int `json:"page"`
+ State string `json:"state"`
+}
+
+// CreatePullRequestOption options when creating a pull request
+type CreatePullRequestOption struct {
+ Head string `json:"head" binding:"Required"`
+ Base string `json:"base" binding:"Required"`
+ Title string `json:"title" binding:"Required"`
+ Body string `json:"body"`
+ Assignee string `json:"assignee"`
+ Assignees []string `json:"assignees"`
+ Milestone int64 `json:"milestone"`
+ Labels []int64 `json:"labels"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+}
+
+// EditPullRequestOption options when modify pull request
+type EditPullRequestOption struct {
+ Title string `json:"title"`
+ Body *string `json:"body"`
+ Base string `json:"base"`
+ Assignee string `json:"assignee"`
+ Assignees []string `json:"assignees"`
+ Milestone int64 `json:"milestone"`
+ Labels []int64 `json:"labels"`
+ State *string `json:"state"`
+ // swagger:strfmt date-time
+ Deadline *time.Time `json:"due_date"`
+ RemoveDeadline *bool `json:"unset_due_date"`
+ AllowMaintainerEdit *bool `json:"allow_maintainer_edit"`
+}
+
+// ChangedFile store information about files affected by the pull request
+type ChangedFile struct {
+ Filename string `json:"filename"`
+ PreviousFilename string `json:"previous_filename,omitempty"`
+ Status string `json:"status"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ Changes int `json:"changes"`
+ HTMLURL string `json:"html_url,omitempty"`
+ ContentsURL string `json:"contents_url,omitempty"`
+ RawURL string `json:"raw_url,omitempty"`
+}
diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go
new file mode 100644
index 0000000..c77ebea
--- /dev/null
+++ b/modules/structs/pull_review.go
@@ -0,0 +1,111 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// ReviewStateType review state type
+type ReviewStateType string
+
+const (
+ // ReviewStateApproved pr is approved
+ ReviewStateApproved ReviewStateType = "APPROVED"
+ // ReviewStatePending pr state is pending
+ ReviewStatePending ReviewStateType = "PENDING"
+ // ReviewStateComment is a comment review
+ ReviewStateComment ReviewStateType = "COMMENT"
+ // ReviewStateRequestChanges changes for pr are requested
+ ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES"
+ // ReviewStateRequestReview review is requested from user
+ ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW"
+ // ReviewStateUnknown state of pr is unknown
+ ReviewStateUnknown ReviewStateType = ""
+)
+
+// PullReview represents a pull request review
+type PullReview struct {
+ ID int64 `json:"id"`
+ Reviewer *User `json:"user"`
+ ReviewerTeam *Team `json:"team"`
+ State ReviewStateType `json:"state"`
+ Body string `json:"body"`
+ CommitID string `json:"commit_id"`
+ Stale bool `json:"stale"`
+ Official bool `json:"official"`
+ Dismissed bool `json:"dismissed"`
+ CodeCommentsCount int `json:"comments_count"`
+ // swagger:strfmt date-time
+ Submitted time.Time `json:"submitted_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+
+ HTMLURL string `json:"html_url"`
+ HTMLPullURL string `json:"pull_request_url"`
+}
+
+// PullReviewComment represents a comment on a pull request review
+type PullReviewComment struct {
+ ID int64 `json:"id"`
+ Body string `json:"body"`
+ Poster *User `json:"user"`
+ Resolver *User `json:"resolver"`
+ ReviewID int64 `json:"pull_request_review_id"`
+
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+
+ Path string `json:"path"`
+ CommitID string `json:"commit_id"`
+ OrigCommitID string `json:"original_commit_id"`
+ DiffHunk string `json:"diff_hunk"`
+ LineNum uint64 `json:"position"`
+ OldLineNum uint64 `json:"original_position"`
+
+ HTMLURL string `json:"html_url"`
+ HTMLPullURL string `json:"pull_request_url"`
+}
+
+// CreatePullReviewOptions are options to create a pull review
+type CreatePullReviewOptions struct {
+ Event ReviewStateType `json:"event"`
+ Body string `json:"body"`
+ CommitID string `json:"commit_id"`
+ Comments []CreatePullReviewComment `json:"comments"`
+}
+
+// CreatePullReviewComment represent a review comment for creation api
+type CreatePullReviewComment struct {
+ // the tree path
+ Path string `json:"path"`
+ Body string `json:"body"`
+ // if comment to old file line or 0
+ OldLineNum int64 `json:"old_position"`
+ // if comment to new file line or 0
+ NewLineNum int64 `json:"new_position"`
+}
+
+// CreatePullReviewCommentOptions are options to create a pull review comment
+type CreatePullReviewCommentOptions CreatePullReviewComment
+
+// SubmitPullReviewOptions are options to submit a pending pull review
+type SubmitPullReviewOptions struct {
+ Event ReviewStateType `json:"event"`
+ Body string `json:"body"`
+}
+
+// DismissPullReviewOptions are options to dismiss a pull review
+type DismissPullReviewOptions struct {
+ Message string `json:"message"`
+ Priors bool `json:"priors"`
+}
+
+// PullReviewRequestOptions are options to add or remove pull review requests
+type PullReviewRequestOptions struct {
+ Reviewers []string `json:"reviewers"`
+ TeamReviewers []string `json:"team_reviewers"`
+}
diff --git a/modules/structs/quota.go b/modules/structs/quota.go
new file mode 100644
index 0000000..cb8874a
--- /dev/null
+++ b/modules/structs/quota.go
@@ -0,0 +1,163 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// QuotaInfo represents information about a user's quota
+type QuotaInfo struct {
+ Used QuotaUsed `json:"used"`
+ Groups QuotaGroupList `json:"groups"`
+}
+
+// QuotaUsed represents the quota usage of a user
+type QuotaUsed struct {
+ Size QuotaUsedSize `json:"size"`
+}
+
+// QuotaUsedSize represents the size-based quota usage of a user
+type QuotaUsedSize struct {
+ Repos QuotaUsedSizeRepos `json:"repos"`
+ Git QuotaUsedSizeGit `json:"git"`
+ Assets QuotaUsedSizeAssets `json:"assets"`
+}
+
+// QuotaUsedSizeRepos represents the size-based repository quota usage of a user
+type QuotaUsedSizeRepos struct {
+ // Storage size of the user's public repositories
+ Public int64 `json:"public"`
+ // Storage size of the user's private repositories
+ Private int64 `json:"private"`
+}
+
+// QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user
+type QuotaUsedSizeGit struct {
+ // Storage size of the user's Git LFS objects
+ LFS int64 `json:"LFS"`
+}
+
+// QuotaUsedSizeAssets represents the size-based asset usage of a user
+type QuotaUsedSizeAssets struct {
+ Attachments QuotaUsedSizeAssetsAttachments `json:"attachments"`
+ // Storage size used for the user's artifacts
+ Artifacts int64 `json:"artifacts"`
+ Packages QuotaUsedSizeAssetsPackages `json:"packages"`
+}
+
+// QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user
+type QuotaUsedSizeAssetsAttachments struct {
+ // Storage size used for the user's issue & comment attachments
+ Issues int64 `json:"issues"`
+ // Storage size used for the user's release attachments
+ Releases int64 `json:"releases"`
+}
+
+// QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user
+type QuotaUsedSizeAssetsPackages struct {
+ // Storage suze used for the user's packages
+ All int64 `json:"all"`
+}
+
+// QuotaRuleInfo contains information about a quota rule
+type QuotaRuleInfo struct {
+ // Name of the rule (only shown to admins)
+ Name string `json:"name,omitempty"`
+ // The limit set by the rule
+ Limit int64 `json:"limit"`
+ // Subjects the rule affects
+ Subjects []string `json:"subjects,omitempty"`
+}
+
+// QuotaGroupList represents a list of quota groups
+type QuotaGroupList []QuotaGroup
+
+// QuotaGroup represents a quota group
+type QuotaGroup struct {
+ // Name of the group
+ Name string `json:"name,omitempty"`
+ // Rules associated with the group
+ Rules []QuotaRuleInfo `json:"rules"`
+}
+
+// CreateQutaGroupOptions represents the options for creating a quota group
+type CreateQuotaGroupOptions struct {
+ // Name of the quota group to create
+ Name string `json:"name" binding:"Required"`
+ // Rules to add to the newly created group.
+ // If a rule does not exist, it will be created.
+ Rules []CreateQuotaRuleOptions `json:"rules"`
+}
+
+// CreateQuotaRuleOptions represents the options for creating a quota rule
+type CreateQuotaRuleOptions struct {
+ // Name of the rule to create
+ Name string `json:"name" binding:"Required"`
+ // The limit set by the rule
+ Limit *int64 `json:"limit"`
+ // The subjects affected by the rule
+ Subjects []string `json:"subjects"`
+}
+
+// EditQuotaRuleOptions represents the options for editing a quota rule
+type EditQuotaRuleOptions struct {
+ // The limit set by the rule
+ Limit *int64 `json:"limit"`
+ // The subjects affected by the rule
+ Subjects *[]string `json:"subjects"`
+}
+
+// SetUserQuotaGroupsOptions represents the quota groups of a user
+type SetUserQuotaGroupsOptions struct {
+ // Quota groups the user shall have
+ // required: true
+ Groups *[]string `json:"groups"`
+}
+
+// QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota
+type QuotaUsedAttachmentList []*QuotaUsedAttachment
+
+// QuotaUsedAttachment represents an attachment counting towards a user's quota
+type QuotaUsedAttachment struct {
+ // Filename of the attachment
+ Name string `json:"name"`
+ // Size of the attachment (in bytes)
+ Size int64 `json:"size"`
+ // API URL for the attachment
+ APIURL string `json:"api_url"`
+ // Context for the attachment: URLs to the containing object
+ ContainedIn struct {
+ // API URL for the object that contains this attachment
+ APIURL string `json:"api_url"`
+ // HTML URL for the object that contains this attachment
+ HTMLURL string `json:"html_url"`
+ } `json:"contained_in"`
+}
+
+// QuotaUsedPackageList represents a list of packages counting towards a user's quota
+type QuotaUsedPackageList []*QuotaUsedPackage
+
+// QuotaUsedPackage represents a package counting towards a user's quota
+type QuotaUsedPackage struct {
+ // Name of the package
+ Name string `json:"name"`
+ // Type of the package
+ Type string `json:"type"`
+ // Version of the package
+ Version string `json:"version"`
+ // Size of the package version
+ Size int64 `json:"size"`
+ // HTML URL to the package version
+ HTMLURL string `json:"html_url"`
+}
+
+// QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota
+type QuotaUsedArtifactList []*QuotaUsedArtifact
+
+// QuotaUsedArtifact represents an artifact counting towards a user's quota
+type QuotaUsedArtifact struct {
+ // Name of the artifact
+ Name string `json:"name"`
+ // Size of the artifact (compressed)
+ Size int64 `json:"size"`
+ // HTML URL to the action run containing the artifact
+ HTMLURL string `json:"html_url"`
+}
diff --git a/modules/structs/release.go b/modules/structs/release.go
new file mode 100644
index 0000000..d8da924
--- /dev/null
+++ b/modules/structs/release.go
@@ -0,0 +1,55 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Release represents a repository release
+type Release struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Target string `json:"target_commitish"`
+ Title string `json:"name"`
+ Note string `json:"body"`
+ URL string `json:"url"`
+ HTMLURL string `json:"html_url"`
+ TarURL string `json:"tarball_url"`
+ ZipURL string `json:"zipball_url"`
+ HideArchiveLinks bool `json:"hide_archive_links"`
+ UploadURL string `json:"upload_url"`
+ IsDraft bool `json:"draft"`
+ IsPrerelease bool `json:"prerelease"`
+ // swagger:strfmt date-time
+ CreatedAt time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ PublishedAt time.Time `json:"published_at"`
+ Publisher *User `json:"author"`
+ Attachments []*Attachment `json:"assets"`
+ ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count"`
+}
+
+// CreateReleaseOption options when creating a release
+type CreateReleaseOption struct {
+ // required: true
+ TagName string `json:"tag_name" binding:"Required"`
+ Target string `json:"target_commitish"`
+ Title string `json:"name"`
+ Note string `json:"body"`
+ IsDraft bool `json:"draft"`
+ IsPrerelease bool `json:"prerelease"`
+ HideArchiveLinks bool `json:"hide_archive_links"`
+}
+
+// EditReleaseOption options when editing a release
+type EditReleaseOption struct {
+ TagName string `json:"tag_name"`
+ Target string `json:"target_commitish"`
+ Title string `json:"name"`
+ Note string `json:"body"`
+ IsDraft *bool `json:"draft"`
+ IsPrerelease *bool `json:"prerelease"`
+ HideArchiveLinks *bool `json:"hide_archive_links"`
+}
diff --git a/modules/structs/repo.go b/modules/structs/repo.go
new file mode 100644
index 0000000..f2fe9c7
--- /dev/null
+++ b/modules/structs/repo.go
@@ -0,0 +1,421 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "strings"
+ "time"
+)
+
+// Permission represents a set of permissions
+type Permission struct {
+ Admin bool `json:"admin"` // Admin indicates if the user is an administrator of the repository.
+ Push bool `json:"push"` // Push indicates if the user can push code to the repository.
+ Pull bool `json:"pull"` // Pull indicates if the user can pull code from the repository.
+}
+
+// InternalTracker represents settings for internal tracker
+// swagger:model
+type InternalTracker struct {
+ // Enable time tracking (Built-in issue tracker)
+ EnableTimeTracker bool `json:"enable_time_tracker"`
+ // Let only contributors track time (Built-in issue tracker)
+ AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
+ // Enable dependencies for issues and pull requests (Built-in issue tracker)
+ EnableIssueDependencies bool `json:"enable_issue_dependencies"`
+}
+
+// ExternalTracker represents settings for external tracker
+// swagger:model
+type ExternalTracker struct {
+ // URL of external issue tracker.
+ ExternalTrackerURL string `json:"external_tracker_url"`
+ // External Issue Tracker URL Format. Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index.
+ ExternalTrackerFormat string `json:"external_tracker_format"`
+ // External Issue Tracker Number Format, either `numeric`, `alphanumeric`, or `regexp`
+ ExternalTrackerStyle string `json:"external_tracker_style"`
+ // External Issue Tracker issue regular expression
+ ExternalTrackerRegexpPattern string `json:"external_tracker_regexp_pattern"`
+}
+
+// ExternalWiki represents setting for external wiki
+// swagger:model
+type ExternalWiki struct {
+ // URL of external wiki.
+ ExternalWikiURL string `json:"external_wiki_url"`
+}
+
+// Repository represents a repository
+type Repository struct {
+ ID int64 `json:"id"`
+ Owner *User `json:"owner"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ Empty bool `json:"empty"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ Template bool `json:"template"`
+ Parent *Repository `json:"parent"`
+ Mirror bool `json:"mirror"`
+ Size int `json:"size"`
+ Language string `json:"language"`
+ LanguagesURL string `json:"languages_url"`
+ HTMLURL string `json:"html_url"`
+ URL string `json:"url"`
+ Link string `json:"link"`
+ SSHURL string `json:"ssh_url"`
+ CloneURL string `json:"clone_url"`
+ OriginalURL string `json:"original_url"`
+ Website string `json:"website"`
+ Stars int `json:"stars_count"`
+ Forks int `json:"forks_count"`
+ Watchers int `json:"watchers_count"`
+ OpenIssues int `json:"open_issues_count"`
+ OpenPulls int `json:"open_pr_counter"`
+ Releases int `json:"release_counter"`
+ DefaultBranch string `json:"default_branch"`
+ Archived bool `json:"archived"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+ ArchivedAt time.Time `json:"archived_at"`
+ Permissions *Permission `json:"permissions,omitempty"`
+ HasIssues bool `json:"has_issues"`
+ InternalTracker *InternalTracker `json:"internal_tracker,omitempty"`
+ ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"`
+ HasWiki bool `json:"has_wiki"`
+ ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"`
+ WikiBranch string `json:"wiki_branch,omitempty"`
+ GloballyEditableWiki bool `json:"globally_editable_wiki"`
+ HasPullRequests bool `json:"has_pull_requests"`
+ HasProjects bool `json:"has_projects"`
+ HasReleases bool `json:"has_releases"`
+ HasPackages bool `json:"has_packages"`
+ HasActions bool `json:"has_actions"`
+ IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
+ AllowMerge bool `json:"allow_merge_commits"`
+ AllowRebase bool `json:"allow_rebase"`
+ AllowRebaseMerge bool `json:"allow_rebase_explicit"`
+ AllowSquash bool `json:"allow_squash_merge"`
+ AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge"`
+ AllowRebaseUpdate bool `json:"allow_rebase_update"`
+ DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
+ DefaultMergeStyle string `json:"default_merge_style"`
+ DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit"`
+ AvatarURL string `json:"avatar_url"`
+ Internal bool `json:"internal"`
+ MirrorInterval string `json:"mirror_interval"`
+ // ObjectFormatName of the underlying git repository
+ // enum: ["sha1", "sha256"]
+ ObjectFormatName string `json:"object_format_name"`
+ // swagger:strfmt date-time
+ MirrorUpdated time.Time `json:"mirror_updated,omitempty"`
+ RepoTransfer *RepoTransfer `json:"repo_transfer"`
+ Topics []string `json:"topics"`
+}
+
+// GetName implements the gitrepo.Repository interface
+func (r Repository) GetName() string {
+ return r.Name
+}
+
+// GetOwnerName implements the gitrepo.Repository interface
+func (r Repository) GetOwnerName() string {
+ return r.Owner.UserName
+}
+
+// CreateRepoOption options when creating repository
+// swagger:model
+type CreateRepoOption struct {
+ // Name of the repository to create
+ //
+ // required: true
+ // unique: true
+ Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(100)"`
+ // Description of the repository to create
+ Description string `json:"description" binding:"MaxSize(2048)"`
+ // Whether the repository is private
+ Private bool `json:"private"`
+ // Label-Set to use
+ IssueLabels string `json:"issue_labels"`
+ // Whether the repository should be auto-initialized?
+ AutoInit bool `json:"auto_init"`
+ // Whether the repository is template
+ Template bool `json:"template"`
+ // Gitignores to use
+ Gitignores string `json:"gitignores"`
+ // License to use
+ License string `json:"license"`
+ // Readme of the repository to create
+ Readme string `json:"readme"`
+ // DefaultBranch of the repository (used when initializes and in template)
+ DefaultBranch string `json:"default_branch" binding:"GitRefName;MaxSize(100)"`
+ // TrustModel of the repository
+ // enum: ["default", "collaborator", "committer", "collaboratorcommitter"]
+ TrustModel string `json:"trust_model"`
+ // ObjectFormatName of the underlying git repository
+ // enum: ["sha1", "sha256"]
+ ObjectFormatName string `json:"object_format_name" binding:"MaxSize(6)"`
+}
+
+// EditRepoOption options when editing a repository's properties
+// swagger:model
+type EditRepoOption struct {
+ // name of the repository
+ // unique: true
+ Name *string `json:"name,omitempty" binding:"OmitEmpty;AlphaDashDot;MaxSize(100);"`
+ // a short description of the repository.
+ Description *string `json:"description,omitempty" binding:"MaxSize(2048)"`
+ // a URL with more information about the repository.
+ Website *string `json:"website,omitempty" binding:"MaxSize(1024)"`
+ // either `true` to make the repository private or `false` to make it public.
+ // Note: you will get a 422 error if the organization restricts changing repository visibility to organization
+ // owners and a non-owner tries to change the value of private.
+ Private *bool `json:"private,omitempty"`
+ // either `true` to make this repository a template or `false` to make it a normal repository
+ Template *bool `json:"template,omitempty"`
+ // either `true` to enable issues for this repository or `false` to disable them.
+ HasIssues *bool `json:"has_issues,omitempty"`
+ // set this structure to configure internal issue tracker
+ InternalTracker *InternalTracker `json:"internal_tracker,omitempty"`
+ // set this structure to use external issue tracker
+ ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"`
+ // either `true` to enable the wiki for this repository or `false` to disable it.
+ HasWiki *bool `json:"has_wiki,omitempty"`
+ // set this structure to use external wiki instead of internal
+ ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"`
+ // set the globally editable state of the wiki
+ GloballyEditableWiki *bool `json:"globally_editable_wiki,omitempty"`
+ // sets the default branch for this repository.
+ DefaultBranch *string `json:"default_branch,omitempty"`
+ // sets the branch used for this repository's wiki.
+ WikiBranch *string `json:"wiki_branch,omitempty"`
+ // either `true` to allow pull requests, or `false` to prevent pull request.
+ HasPullRequests *bool `json:"has_pull_requests,omitempty"`
+ // either `true` to enable project unit, or `false` to disable them.
+ HasProjects *bool `json:"has_projects,omitempty"`
+ // either `true` to enable releases unit, or `false` to disable them.
+ HasReleases *bool `json:"has_releases,omitempty"`
+ // either `true` to enable packages unit, or `false` to disable them.
+ HasPackages *bool `json:"has_packages,omitempty"`
+ // either `true` to enable actions unit, or `false` to disable them.
+ HasActions *bool `json:"has_actions,omitempty"`
+ // either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace.
+ IgnoreWhitespaceConflicts *bool `json:"ignore_whitespace_conflicts,omitempty"`
+ // either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.
+ AllowMerge *bool `json:"allow_merge_commits,omitempty"`
+ // either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.
+ AllowRebase *bool `json:"allow_rebase,omitempty"`
+ // either `true` to allow rebase with explicit merge commits (--no-ff), or `false` to prevent rebase with explicit merge commits.
+ AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"`
+ // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.
+ AllowSquash *bool `json:"allow_squash_merge,omitempty"`
+ // either `true` to allow fast-forward-only merging pull requests, or `false` to prevent fast-forward-only merging.
+ AllowFastForwardOnly *bool `json:"allow_fast_forward_only_merge,omitempty"`
+ // either `true` to allow mark pr as merged manually, or `false` to prevent it.
+ AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
+ // either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur.
+ AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"`
+ // either `true` to allow updating pull request branch by rebase, or `false` to prevent it.
+ AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"`
+ // set to `true` to delete pr branch after merge by default
+ DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"`
+ // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only".
+ DefaultMergeStyle *string `json:"default_merge_style,omitempty"`
+ // set to `true` to allow edits from maintainers by default
+ DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"`
+ // set to `true` to archive this repository.
+ Archived *bool `json:"archived,omitempty"`
+ // set to a string like `8h30m0s` to set the mirror interval time
+ MirrorInterval *string `json:"mirror_interval,omitempty"`
+ // enable prune - remove obsolete remote-tracking references when mirroring
+ EnablePrune *bool `json:"enable_prune,omitempty"`
+}
+
+// GenerateRepoOption options when creating repository using a template
+// swagger:model
+type GenerateRepoOption struct {
+ // The organization or person who will own the new repository
+ //
+ // required: true
+ Owner string `json:"owner"`
+ // Name of the repository to create
+ //
+ // required: true
+ // unique: true
+ Name string `json:"name" binding:"Required;AlphaDashDot;MaxSize(100)"`
+ // Default branch of the new repository
+ DefaultBranch string `json:"default_branch"`
+ // Description of the repository to create
+ Description string `json:"description" binding:"MaxSize(2048)"`
+ // Whether the repository is private
+ Private bool `json:"private"`
+ // include git content of default branch in template repo
+ GitContent bool `json:"git_content"`
+ // include topics in template repo
+ Topics bool `json:"topics"`
+ // include git hooks in template repo
+ GitHooks bool `json:"git_hooks"`
+ // include webhooks in template repo
+ Webhooks bool `json:"webhooks"`
+ // include avatar of the template repo
+ Avatar bool `json:"avatar"`
+ // include labels in template repo
+ Labels bool `json:"labels"`
+ // include protected branches in template repo
+ ProtectedBranch bool `json:"protected_branch"`
+}
+
+// CreateBranchRepoOption options when creating a branch in a repository
+// swagger:model
+type CreateBranchRepoOption struct {
+ // Name of the branch to create
+ //
+ // required: true
+ // unique: true
+ BranchName string `json:"new_branch_name" binding:"Required;GitRefName;MaxSize(100)"`
+
+ // Deprecated: true
+ // Name of the old branch to create from
+ //
+ // unique: true
+ OldBranchName string `json:"old_branch_name" binding:"GitRefName;MaxSize(100)"`
+
+ // Name of the old branch/tag/commit to create from
+ //
+ // unique: true
+ OldRefName string `json:"old_ref_name" binding:"GitRefName;MaxSize(100)"`
+}
+
+// TransferRepoOption options when transfer a repository's ownership
+// swagger:model
+type TransferRepoOption struct {
+ // required: true
+ NewOwner string `json:"new_owner"`
+ // ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories.
+ TeamIDs *[]int64 `json:"team_ids"`
+}
+
+// GitServiceType represents a git service
+type GitServiceType int
+
+// enumerate all GitServiceType
+const (
+ NotMigrated GitServiceType = iota // 0 not migrated from external sites
+ PlainGitService // 1 plain git service
+ GithubService // 2 github.com
+ GiteaService // 3 gitea service
+ GitlabService // 4 gitlab service
+ GogsService // 5 gogs service
+ OneDevService // 6 onedev service
+ GitBucketService // 7 gitbucket service
+ CodebaseService // 8 codebase service
+ ForgejoService // 9 forgejo service
+)
+
+// Name represents the service type's name
+// WARNING: the name have to be equal to that on goth's library
+func (gt GitServiceType) Name() string {
+ return strings.ToLower(gt.Title())
+}
+
+// Title represents the service type's proper title
+func (gt GitServiceType) Title() string {
+ switch gt {
+ case GithubService:
+ return "GitHub"
+ case GiteaService:
+ return "Gitea"
+ case GitlabService:
+ return "GitLab"
+ case GogsService:
+ return "Gogs"
+ case OneDevService:
+ return "OneDev"
+ case GitBucketService:
+ return "GitBucket"
+ case CodebaseService:
+ return "Codebase"
+ case ForgejoService:
+ return "Forgejo"
+ case PlainGitService:
+ return "Git"
+ }
+ return ""
+}
+
+// MigrateRepoOptions options for migrating repository's
+// this is used to interact with api v1
+type MigrateRepoOptions struct {
+ // required: true
+ CloneAddr string `json:"clone_addr" binding:"Required"`
+ // deprecated (only for backwards compatibility)
+ RepoOwnerID int64 `json:"uid"`
+ // Name of User or Organisation who will own Repo after migration
+ RepoOwner string `json:"repo_owner"`
+ // required: true
+ RepoName string `json:"repo_name" binding:"Required;AlphaDashDot;MaxSize(100)"`
+
+ // enum: ["git", "github", "gitea", "gitlab", "gogs", "onedev", "gitbucket", "codebase"]
+ Service string `json:"service"`
+ AuthUsername string `json:"auth_username"`
+ AuthPassword string `json:"auth_password"`
+ AuthToken string `json:"auth_token"`
+
+ Mirror bool `json:"mirror"`
+ LFS bool `json:"lfs"`
+ LFSEndpoint string `json:"lfs_endpoint"`
+ Private bool `json:"private"`
+ Description string `json:"description" binding:"MaxSize(2048)"`
+ Wiki bool `json:"wiki"`
+ Milestones bool `json:"milestones"`
+ Labels bool `json:"labels"`
+ Issues bool `json:"issues"`
+ PullRequests bool `json:"pull_requests"`
+ Releases bool `json:"releases"`
+ MirrorInterval string `json:"mirror_interval"`
+}
+
+// TokenAuth represents whether a service type supports token-based auth
+func (gt GitServiceType) TokenAuth() bool {
+ switch gt {
+ case GithubService, GiteaService, GitlabService, ForgejoService:
+ return true
+ }
+ return false
+}
+
+// SupportedFullGitService represents all git services supported to migrate issues/labels/prs and etc.
+// TODO: add to this list after new git service added
+var SupportedFullGitService = []GitServiceType{
+ GithubService,
+ GitlabService,
+ ForgejoService,
+ GiteaService,
+ GogsService,
+ OneDevService,
+ GitBucketService,
+ CodebaseService,
+}
+
+// RepoTransfer represents a pending repo transfer
+type RepoTransfer struct {
+ Doer *User `json:"doer"`
+ Recipient *User `json:"recipient"`
+ Teams []*Team `json:"teams"`
+}
+
+// NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed
+type NewIssuePinsAllowed struct {
+ Issues bool `json:"issues"`
+ PullRequests bool `json:"pull_requests"`
+}
+
+// UpdateRepoAvatarUserOption options when updating the repo avatar
+type UpdateRepoAvatarOption struct {
+ // image must be base64 encoded
+ Image string `json:"image" binding:"Required"`
+}
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
new file mode 100644
index 0000000..b13f344
--- /dev/null
+++ b/modules/structs/repo_actions.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// ActionTask represents a ActionTask
+type ActionTask struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ HeadBranch string `json:"head_branch"`
+ HeadSHA string `json:"head_sha"`
+ RunNumber int64 `json:"run_number"`
+ Event string `json:"event"`
+ DisplayTitle string `json:"display_title"`
+ Status string `json:"status"`
+ WorkflowID string `json:"workflow_id"`
+ URL string `json:"url"`
+ // swagger:strfmt date-time
+ CreatedAt time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ UpdatedAt time.Time `json:"updated_at"`
+ // swagger:strfmt date-time
+ RunStartedAt time.Time `json:"run_started_at"`
+}
+
+// ActionTaskResponse returns a ActionTask
+type ActionTaskResponse struct {
+ Entries []*ActionTask `json:"workflow_runs"`
+ TotalCount int64 `json:"total_count"`
+}
diff --git a/modules/structs/repo_branch.go b/modules/structs/repo_branch.go
new file mode 100644
index 0000000..0b3b0bb
--- /dev/null
+++ b/modules/structs/repo_branch.go
@@ -0,0 +1,112 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Branch represents a repository branch
+type Branch struct {
+ Name string `json:"name"`
+ Commit *PayloadCommit `json:"commit"`
+ Protected bool `json:"protected"`
+ RequiredApprovals int64 `json:"required_approvals"`
+ EnableStatusCheck bool `json:"enable_status_check"`
+ StatusCheckContexts []string `json:"status_check_contexts"`
+ UserCanPush bool `json:"user_can_push"`
+ UserCanMerge bool `json:"user_can_merge"`
+ EffectiveBranchProtectionName string `json:"effective_branch_protection_name"`
+}
+
+// BranchProtection represents a branch protection for a repository
+type BranchProtection struct {
+ // Deprecated: true
+ BranchName string `json:"branch_name"`
+ RuleName string `json:"rule_name"`
+ EnablePush bool `json:"enable_push"`
+ EnablePushWhitelist bool `json:"enable_push_whitelist"`
+ PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
+ PushWhitelistTeams []string `json:"push_whitelist_teams"`
+ PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
+ EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
+ MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
+ MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
+ EnableStatusCheck bool `json:"enable_status_check"`
+ StatusCheckContexts []string `json:"status_check_contexts"`
+ RequiredApprovals int64 `json:"required_approvals"`
+ EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
+ ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
+ ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
+ BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
+ BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
+ BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
+ DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
+ IgnoreStaleApprovals bool `json:"ignore_stale_approvals"`
+ RequireSignedCommits bool `json:"require_signed_commits"`
+ ProtectedFilePatterns string `json:"protected_file_patterns"`
+ UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
+ ApplyToAdmins bool `json:"apply_to_admins"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+}
+
+// CreateBranchProtectionOption options for creating a branch protection
+type CreateBranchProtectionOption struct {
+ // Deprecated: true
+ BranchName string `json:"branch_name"`
+ RuleName string `json:"rule_name"`
+ EnablePush bool `json:"enable_push"`
+ EnablePushWhitelist bool `json:"enable_push_whitelist"`
+ PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
+ PushWhitelistTeams []string `json:"push_whitelist_teams"`
+ PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
+ EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
+ MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
+ MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
+ EnableStatusCheck bool `json:"enable_status_check"`
+ StatusCheckContexts []string `json:"status_check_contexts"`
+ RequiredApprovals int64 `json:"required_approvals"`
+ EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
+ ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
+ ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
+ BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
+ BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
+ BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
+ DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
+ IgnoreStaleApprovals bool `json:"ignore_stale_approvals"`
+ RequireSignedCommits bool `json:"require_signed_commits"`
+ ProtectedFilePatterns string `json:"protected_file_patterns"`
+ UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
+ ApplyToAdmins bool `json:"apply_to_admins"`
+}
+
+// EditBranchProtectionOption options for editing a branch protection
+type EditBranchProtectionOption struct {
+ EnablePush *bool `json:"enable_push"`
+ EnablePushWhitelist *bool `json:"enable_push_whitelist"`
+ PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
+ PushWhitelistTeams []string `json:"push_whitelist_teams"`
+ PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"`
+ EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
+ MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
+ MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
+ EnableStatusCheck *bool `json:"enable_status_check"`
+ StatusCheckContexts []string `json:"status_check_contexts"`
+ RequiredApprovals *int64 `json:"required_approvals"`
+ EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"`
+ ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
+ ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
+ BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
+ BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"`
+ BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"`
+ DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
+ IgnoreStaleApprovals *bool `json:"ignore_stale_approvals"`
+ RequireSignedCommits *bool `json:"require_signed_commits"`
+ ProtectedFilePatterns *string `json:"protected_file_patterns"`
+ UnprotectedFilePatterns *string `json:"unprotected_file_patterns"`
+ ApplyToAdmins *bool `json:"apply_to_admins"`
+}
diff --git a/modules/structs/repo_collaborator.go b/modules/structs/repo_collaborator.go
new file mode 100644
index 0000000..2f03f0a
--- /dev/null
+++ b/modules/structs/repo_collaborator.go
@@ -0,0 +1,17 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// AddCollaboratorOption options when adding a user as a collaborator of a repository
+type AddCollaboratorOption struct {
+ // enum: ["read", "write", "admin"]
+ Permission *string `json:"permission"`
+}
+
+// RepoCollaboratorPermission to get repository permission for a collaborator
+type RepoCollaboratorPermission struct {
+ Permission string `json:"permission"`
+ RoleName string `json:"role_name"`
+ User *User `json:"user"`
+}
diff --git a/modules/structs/repo_commit.go b/modules/structs/repo_commit.go
new file mode 100644
index 0000000..fec7d97
--- /dev/null
+++ b/modules/structs/repo_commit.go
@@ -0,0 +1,73 @@
+// Copyright 2018 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// Identity for a person's identity like an author or committer
+type Identity struct {
+ Name string `json:"name" binding:"MaxSize(100)"`
+ // swagger:strfmt email
+ Email string `json:"email" binding:"MaxSize(254)"`
+}
+
+// CommitMeta contains meta information of a commit in terms of API.
+type CommitMeta struct {
+ URL string `json:"url"`
+ SHA string `json:"sha"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created"`
+}
+
+// CommitUser contains information of a user in the context of a commit.
+type CommitUser struct {
+ Identity
+ Date string `json:"date"`
+}
+
+// RepoCommit contains information of a commit in the context of a repository.
+type RepoCommit struct {
+ URL string `json:"url"`
+ Author *CommitUser `json:"author"`
+ Committer *CommitUser `json:"committer"`
+ Message string `json:"message"`
+ Tree *CommitMeta `json:"tree"`
+ Verification *PayloadCommitVerification `json:"verification"`
+}
+
+// CommitStats is statistics for a RepoCommit
+type CommitStats struct {
+ Total int `json:"total"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+}
+
+// Commit contains information generated from a Git commit.
+type Commit struct {
+ *CommitMeta
+ HTMLURL string `json:"html_url"`
+ RepoCommit *RepoCommit `json:"commit"`
+ Author *User `json:"author"`
+ Committer *User `json:"committer"`
+ Parents []*CommitMeta `json:"parents"`
+ Files []*CommitAffectedFiles `json:"files"`
+ Stats *CommitStats `json:"stats"`
+}
+
+// CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE
+type CommitDateOptions struct {
+ // swagger:strfmt date-time
+ Author time.Time `json:"author"`
+ // swagger:strfmt date-time
+ Committer time.Time `json:"committer"`
+}
+
+// CommitAffectedFiles store information about files affected by the commit
+type CommitAffectedFiles struct {
+ Filename string `json:"filename"`
+ Status string `json:"status"`
+}
diff --git a/modules/structs/repo_compare.go b/modules/structs/repo_compare.go
new file mode 100644
index 0000000..8a12498
--- /dev/null
+++ b/modules/structs/repo_compare.go
@@ -0,0 +1,10 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Compare represents a comparison between two commits.
+type Compare struct {
+ TotalCommits int `json:"total_commits"` // Total number of commits in the comparison.
+ Commits []*Commit `json:"commits"` // List of commits in the comparison.
+}
diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go
new file mode 100644
index 0000000..00c8041
--- /dev/null
+++ b/modules/structs/repo_file.go
@@ -0,0 +1,172 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// FileOptions options for all file APIs
+type FileOptions struct {
+ // message (optional) for the commit of this file. if not supplied, a default message will be used
+ Message string `json:"message"`
+ // branch (optional) to base this file from. if not given, the default branch is used
+ BranchName string `json:"branch" binding:"GitRefName;MaxSize(100)"`
+ // new_branch (optional) will make a new branch from `branch` before creating the file
+ NewBranchName string `json:"new_branch" binding:"GitRefName;MaxSize(100)"`
+ // `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+ Author Identity `json:"author"`
+ Committer Identity `json:"committer"`
+ Dates CommitDateOptions `json:"dates"`
+ // Add a Signed-off-by trailer by the committer at the end of the commit log message.
+ Signoff bool `json:"signoff"`
+}
+
+// CreateFileOptions options for creating files
+// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+type CreateFileOptions struct {
+ FileOptions
+ // content must be base64 encoded
+ // required: true
+ ContentBase64 string `json:"content"`
+}
+
+// Branch returns branch name
+func (o *CreateFileOptions) Branch() string {
+ return o.FileOptions.BranchName
+}
+
+// DeleteFileOptions options for deleting files (used for other File structs below)
+// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+type DeleteFileOptions struct {
+ FileOptions
+ // sha is the SHA for the file that already exists
+ // required: true
+ SHA string `json:"sha" binding:"Required"`
+}
+
+// Branch returns branch name
+func (o *DeleteFileOptions) Branch() string {
+ return o.FileOptions.BranchName
+}
+
+// UpdateFileOptions options for updating files
+// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+type UpdateFileOptions struct {
+ DeleteFileOptions
+ // content must be base64 encoded
+ // required: true
+ ContentBase64 string `json:"content"`
+ // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL
+ FromPath string `json:"from_path" binding:"MaxSize(500)"`
+}
+
+// Branch returns branch name
+func (o *UpdateFileOptions) Branch() string {
+ return o.FileOptions.BranchName
+}
+
+// ChangeFileOperation for creating, updating or deleting a file
+type ChangeFileOperation struct {
+ // indicates what to do with the file
+ // required: true
+ // enum: ["create", "update", "delete"]
+ Operation string `json:"operation" binding:"Required"`
+ // path to the existing or new file
+ // required: true
+ Path string `json:"path" binding:"Required;MaxSize(500)"`
+ // new or updated file content, must be base64 encoded
+ ContentBase64 string `json:"content"`
+ // sha is the SHA for the file that already exists, required for update or delete
+ SHA string `json:"sha"`
+ // old path of the file to move
+ FromPath string `json:"from_path"`
+}
+
+// ChangeFilesOptions options for creating, updating or deleting multiple files
+// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+type ChangeFilesOptions struct {
+ FileOptions
+ // list of file operations
+ // required: true
+ Files []*ChangeFileOperation `json:"files" binding:"Required"`
+}
+
+// Branch returns branch name
+func (o *ChangeFilesOptions) Branch() string {
+ return o.FileOptions.BranchName
+}
+
+// FileOptionInterface provides a unified interface for the different file options
+type FileOptionInterface interface {
+ Branch() string
+}
+
+// ApplyDiffPatchFileOptions options for applying a diff patch
+// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
+type ApplyDiffPatchFileOptions struct {
+ DeleteFileOptions
+ // required: true
+ Content string `json:"content"`
+}
+
+// FileLinksResponse contains the links for a repo's file
+type FileLinksResponse struct {
+ Self *string `json:"self"`
+ GitURL *string `json:"git"`
+ HTMLURL *string `json:"html"`
+}
+
+// ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content
+type ContentsResponse struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ SHA string `json:"sha"`
+ LastCommitSHA string `json:"last_commit_sha"`
+ // `type` will be `file`, `dir`, `symlink`, or `submodule`
+ Type string `json:"type"`
+ Size int64 `json:"size"`
+ // `encoding` is populated when `type` is `file`, otherwise null
+ Encoding *string `json:"encoding"`
+ // `content` is populated when `type` is `file`, otherwise null
+ Content *string `json:"content"`
+ // `target` is populated when `type` is `symlink`, otherwise null
+ Target *string `json:"target"`
+ URL *string `json:"url"`
+ HTMLURL *string `json:"html_url"`
+ GitURL *string `json:"git_url"`
+ DownloadURL *string `json:"download_url"`
+ // `submodule_git_url` is populated when `type` is `submodule`, otherwise null
+ SubmoduleGitURL *string `json:"submodule_git_url"`
+ Links *FileLinksResponse `json:"_links"`
+}
+
+// FileCommitResponse contains information generated from a Git commit for a repo's file.
+type FileCommitResponse struct {
+ CommitMeta
+ HTMLURL string `json:"html_url"`
+ Author *CommitUser `json:"author"`
+ Committer *CommitUser `json:"committer"`
+ Parents []*CommitMeta `json:"parents"`
+ Message string `json:"message"`
+ Tree *CommitMeta `json:"tree"`
+}
+
+// FileResponse contains information about a repo's file
+type FileResponse struct {
+ Content *ContentsResponse `json:"content"`
+ Commit *FileCommitResponse `json:"commit"`
+ Verification *PayloadCommitVerification `json:"verification"`
+}
+
+// FilesResponse contains information about multiple files from a repo
+type FilesResponse struct {
+ Files []*ContentsResponse `json:"files"`
+ Commit *FileCommitResponse `json:"commit"`
+ Verification *PayloadCommitVerification `json:"verification"`
+}
+
+// FileDeleteResponse contains information about a repo's file that was deleted
+type FileDeleteResponse struct {
+ Content any `json:"content"` // to be set to nil
+ Commit *FileCommitResponse `json:"commit"`
+ Verification *PayloadCommitVerification `json:"verification"`
+}
diff --git a/modules/structs/repo_flags.go b/modules/structs/repo_flags.go
new file mode 100644
index 0000000..5db7145
--- /dev/null
+++ b/modules/structs/repo_flags.go
@@ -0,0 +1,9 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// ReplaceFlagsOption options when replacing the flags of a repository
+type ReplaceFlagsOption struct {
+ Flags []string `json:"flags"`
+}
diff --git a/modules/structs/repo_key.go b/modules/structs/repo_key.go
new file mode 100644
index 0000000..27b9d05
--- /dev/null
+++ b/modules/structs/repo_key.go
@@ -0,0 +1,40 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// DeployKey a deploy key
+type DeployKey struct {
+ ID int64 `json:"id"`
+ KeyID int64 `json:"key_id"`
+ Key string `json:"key"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Fingerprint string `json:"fingerprint"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ ReadOnly bool `json:"read_only"`
+ Repository *Repository `json:"repository,omitempty"`
+}
+
+// CreateKeyOption options when creating a key
+type CreateKeyOption struct {
+ // Title of the key to add
+ //
+ // required: true
+ // unique: true
+ Title string `json:"title" binding:"Required"`
+ // An armored SSH key to add
+ //
+ // required: true
+ // unique: true
+ Key string `json:"key" binding:"Required"`
+ // Describe if the key has only read access or read/write
+ //
+ // required: false
+ ReadOnly bool `json:"read_only"`
+}
diff --git a/modules/structs/repo_note.go b/modules/structs/repo_note.go
new file mode 100644
index 0000000..4eaf5a2
--- /dev/null
+++ b/modules/structs/repo_note.go
@@ -0,0 +1,10 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Note contains information related to a git note
+type Note struct {
+ Message string `json:"message"`
+ Commit *Commit `json:"commit"`
+}
diff --git a/modules/structs/repo_refs.go b/modules/structs/repo_refs.go
new file mode 100644
index 0000000..6ffbc74
--- /dev/null
+++ b/modules/structs/repo_refs.go
@@ -0,0 +1,18 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Reference represents a Git reference.
+type Reference struct {
+ Ref string `json:"ref"`
+ URL string `json:"url"`
+ Object *GitObject `json:"object"`
+}
+
+// GitObject represents a Git object.
+type GitObject struct {
+ Type string `json:"type"`
+ SHA string `json:"sha"`
+ URL string `json:"url"`
+}
diff --git a/modules/structs/repo_tag.go b/modules/structs/repo_tag.go
new file mode 100644
index 0000000..1bea5b3
--- /dev/null
+++ b/modules/structs/repo_tag.go
@@ -0,0 +1,76 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// Tag represents a repository tag
+type Tag struct {
+ Name string `json:"name"`
+ Message string `json:"message"`
+ ID string `json:"id"`
+ Commit *CommitMeta `json:"commit"`
+ ZipballURL string `json:"zipball_url"`
+ TarballURL string `json:"tarball_url"`
+ ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count"`
+}
+
+// AnnotatedTag represents an annotated tag
+type AnnotatedTag struct {
+ Tag string `json:"tag"`
+ SHA string `json:"sha"`
+ URL string `json:"url"`
+ Message string `json:"message"`
+ Tagger *CommitUser `json:"tagger"`
+ Object *AnnotatedTagObject `json:"object"`
+ Verification *PayloadCommitVerification `json:"verification"`
+ ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count"`
+}
+
+// AnnotatedTagObject contains meta information of the tag object
+type AnnotatedTagObject struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ SHA string `json:"sha"`
+}
+
+// CreateTagOption options when creating a tag
+type CreateTagOption struct {
+ // required: true
+ TagName string `json:"tag_name" binding:"Required"`
+ Message string `json:"message"`
+ Target string `json:"target"`
+}
+
+// TagArchiveDownloadCount counts how many times a archive was downloaded
+type TagArchiveDownloadCount struct {
+ Zip int64 `json:"zip"`
+ TarGz int64 `json:"tar_gz"`
+}
+
+// TagProtection represents a tag protection
+type TagProtection struct {
+ ID int64 `json:"id"`
+ NamePattern string `json:"name_pattern"`
+ WhitelistUsernames []string `json:"whitelist_usernames"`
+ WhitelistTeams []string `json:"whitelist_teams"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+}
+
+// CreateTagProtectionOption options for creating a tag protection
+type CreateTagProtectionOption struct {
+ NamePattern string `json:"name_pattern"`
+ WhitelistUsernames []string `json:"whitelist_usernames"`
+ WhitelistTeams []string `json:"whitelist_teams"`
+}
+
+// EditTagProtectionOption options for editing a tag protection
+type EditTagProtectionOption struct {
+ NamePattern *string `json:"name_pattern"`
+ WhitelistUsernames []string `json:"whitelist_usernames"`
+ WhitelistTeams []string `json:"whitelist_teams"`
+}
diff --git a/modules/structs/repo_topic.go b/modules/structs/repo_topic.go
new file mode 100644
index 0000000..fea193e
--- /dev/null
+++ b/modules/structs/repo_topic.go
@@ -0,0 +1,28 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// TopicResponse for returning topics
+type TopicResponse struct {
+ ID int64 `json:"id"`
+ Name string `json:"topic_name"`
+ RepoCount int `json:"repo_count"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+}
+
+// TopicName a list of repo topic names
+type TopicName struct {
+ TopicNames []string `json:"topics"`
+}
+
+// RepoTopicOptions a collection of repo topic names
+type RepoTopicOptions struct {
+ // list of topic names
+ Topics []string `json:"topics"`
+}
diff --git a/modules/structs/repo_tree.go b/modules/structs/repo_tree.go
new file mode 100644
index 0000000..86b221e
--- /dev/null
+++ b/modules/structs/repo_tree.go
@@ -0,0 +1,24 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// GitEntry represents a git tree
+type GitEntry struct {
+ Path string `json:"path"`
+ Mode string `json:"mode"`
+ Type string `json:"type"`
+ Size int64 `json:"size"`
+ SHA string `json:"sha"`
+ URL string `json:"url"`
+}
+
+// GitTreeResponse returns a git tree
+type GitTreeResponse struct {
+ SHA string `json:"sha"`
+ URL string `json:"url"`
+ Entries []GitEntry `json:"tree"`
+ Truncated bool `json:"truncated"`
+ Page int `json:"page"`
+ TotalCount int `json:"total_count"`
+}
diff --git a/modules/structs/repo_watch.go b/modules/structs/repo_watch.go
new file mode 100644
index 0000000..0d0b7c4
--- /dev/null
+++ b/modules/structs/repo_watch.go
@@ -0,0 +1,18 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// WatchInfo represents an API watch status of one repository
+type WatchInfo struct {
+ Subscribed bool `json:"subscribed"`
+ Ignored bool `json:"ignored"`
+ Reason any `json:"reason"`
+ CreatedAt time.Time `json:"created_at"`
+ URL string `json:"url"`
+ RepositoryURL string `json:"repository_url"`
+}
diff --git a/modules/structs/repo_wiki.go b/modules/structs/repo_wiki.go
new file mode 100644
index 0000000..3df5a0b
--- /dev/null
+++ b/modules/structs/repo_wiki.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// WikiCommit page commit/revision
+type WikiCommit struct {
+ ID string `json:"sha"`
+ Author *CommitUser `json:"author"`
+ Committer *CommitUser `json:"commiter"`
+ Message string `json:"message"`
+}
+
+// WikiPage a wiki page
+type WikiPage struct {
+ *WikiPageMetaData
+ // Page content, base64 encoded
+ ContentBase64 string `json:"content_base64"`
+ CommitCount int64 `json:"commit_count"`
+ Sidebar string `json:"sidebar"`
+ Footer string `json:"footer"`
+}
+
+// WikiPageMetaData wiki page meta information
+type WikiPageMetaData struct {
+ Title string `json:"title"`
+ HTMLURL string `json:"html_url"`
+ SubURL string `json:"sub_url"`
+ LastCommit *WikiCommit `json:"last_commit"`
+}
+
+// CreateWikiPageOptions form for creating wiki
+type CreateWikiPageOptions struct {
+ // page title. leave empty to keep unchanged
+ Title string `json:"title"`
+ // content must be base64 encoded
+ ContentBase64 string `json:"content_base64"`
+ // optional commit message summarizing the change
+ Message string `json:"message"`
+}
+
+// WikiCommitList commit/revision list
+type WikiCommitList struct {
+ WikiCommits []*WikiCommit `json:"commits"`
+ Count int64 `json:"count"`
+}
diff --git a/modules/structs/secret.go b/modules/structs/secret.go
new file mode 100644
index 0000000..a0673ca
--- /dev/null
+++ b/modules/structs/secret.go
@@ -0,0 +1,24 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// Secret represents a secret
+// swagger:model
+type Secret struct {
+ // the secret's name
+ Name string `json:"name"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+}
+
+// CreateOrUpdateSecretOption options when creating or updating secret
+// swagger:model
+type CreateOrUpdateSecretOption struct {
+ // Data of the secret to update
+ //
+ // required: true
+ Data string `json:"data" binding:"Required"`
+}
diff --git a/modules/structs/settings.go b/modules/structs/settings.go
new file mode 100644
index 0000000..b127b58
--- /dev/null
+++ b/modules/structs/settings.go
@@ -0,0 +1,38 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// GeneralRepoSettings contains global repository settings exposed by API
+type GeneralRepoSettings struct {
+ MirrorsDisabled bool `json:"mirrors_disabled"`
+ HTTPGitDisabled bool `json:"http_git_disabled"`
+ MigrationsDisabled bool `json:"migrations_disabled"`
+ StarsDisabled bool `json:"stars_disabled"`
+ ForksDisabled bool `json:"forks_disabled"`
+ TimeTrackingDisabled bool `json:"time_tracking_disabled"`
+ LFSDisabled bool `json:"lfs_disabled"`
+}
+
+// GeneralUISettings contains global ui settings exposed by API
+type GeneralUISettings struct {
+ DefaultTheme string `json:"default_theme"`
+ AllowedReactions []string `json:"allowed_reactions"`
+ CustomEmojis []string `json:"custom_emojis"`
+}
+
+// GeneralAPISettings contains global api settings exposed by it
+type GeneralAPISettings struct {
+ MaxResponseItems int `json:"max_response_items"`
+ DefaultPagingNum int `json:"default_paging_num"`
+ DefaultGitTreesPerPage int `json:"default_git_trees_per_page"`
+ DefaultMaxBlobSize int64 `json:"default_max_blob_size"`
+}
+
+// GeneralAttachmentSettings contains global Attachment settings exposed by API
+type GeneralAttachmentSettings struct {
+ Enabled bool `json:"enabled"`
+ AllowedTypes string `json:"allowed_types"`
+ MaxSize int64 `json:"max_size"`
+ MaxFiles int `json:"max_files"`
+}
diff --git a/modules/structs/status.go b/modules/structs/status.go
new file mode 100644
index 0000000..c1d8b90
--- /dev/null
+++ b/modules/structs/status.go
@@ -0,0 +1,42 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// CommitStatus holds a single status of a single Commit
+type CommitStatus struct {
+ ID int64 `json:"id"`
+ State CommitStatusState `json:"status"`
+ TargetURL string `json:"target_url"`
+ Description string `json:"description"`
+ URL string `json:"url"`
+ Context string `json:"context"`
+ Creator *User `json:"creator"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at"`
+ // swagger:strfmt date-time
+ Updated time.Time `json:"updated_at"`
+}
+
+// CombinedStatus holds the combined state of several statuses for a single commit
+type CombinedStatus struct {
+ State CommitStatusState `json:"state"`
+ SHA string `json:"sha"`
+ TotalCount int `json:"total_count"`
+ Statuses []*CommitStatus `json:"statuses"`
+ Repository *Repository `json:"repository"`
+ CommitURL string `json:"commit_url"`
+ URL string `json:"url"`
+}
+
+// CreateStatusOption holds the information needed to create a new CommitStatus for a Commit
+type CreateStatusOption struct {
+ State CommitStatusState `json:"state"`
+ TargetURL string `json:"target_url"`
+ Description string `json:"description"`
+ Context string `json:"context"`
+}
diff --git a/modules/structs/task.go b/modules/structs/task.go
new file mode 100644
index 0000000..84b6181
--- /dev/null
+++ b/modules/structs/task.go
@@ -0,0 +1,31 @@
+// Copyright 2019 Gitea. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// TaskType defines task type
+type TaskType int
+
+const TaskTypeMigrateRepo TaskType = iota // migrate repository from external or local disk
+
+// Name returns the task type name
+func (taskType TaskType) Name() string {
+ switch taskType {
+ case TaskTypeMigrateRepo:
+ return "Migrate Repository"
+ default:
+ return ""
+ }
+}
+
+// TaskStatus defines task status
+type TaskStatus int
+
+// enumerate all the kinds of task status
+const (
+ TaskStatusQueued TaskStatus = iota // 0 task is queued
+ TaskStatusRunning // 1 task is running
+ TaskStatusStopped // 2 task is stopped (never used)
+ TaskStatusFailed // 3 task is failed
+ TaskStatusFinished // 4 task is finished
+)
diff --git a/modules/structs/user.go b/modules/structs/user.go
new file mode 100644
index 0000000..f2747b1
--- /dev/null
+++ b/modules/structs/user.go
@@ -0,0 +1,120 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+// User represents a user
+// swagger:model
+type User struct {
+ // the user's id
+ ID int64 `json:"id"`
+ // the user's username
+ UserName string `json:"login"`
+ // the user's authentication sign-in name.
+ // default: empty
+ LoginName string `json:"login_name"`
+ // The ID of the user's Authentication Source
+ SourceID int64 `json:"source_id"`
+ // the user's full name
+ FullName string `json:"full_name"`
+ // swagger:strfmt email
+ Email string `json:"email"`
+ // URL to the user's avatar
+ AvatarURL string `json:"avatar_url"`
+ // URL to the user's gitea page
+ HTMLURL string `json:"html_url"`
+ // User locale
+ Language string `json:"language"`
+ // Is the user an administrator
+ IsAdmin bool `json:"is_admin"`
+ // swagger:strfmt date-time
+ LastLogin time.Time `json:"last_login,omitempty"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created,omitempty"`
+ // Is user restricted
+ Restricted bool `json:"restricted"`
+ // Is user active
+ IsActive bool `json:"active"`
+ // Is user login prohibited
+ ProhibitLogin bool `json:"prohibit_login"`
+ // the user's location
+ Location string `json:"location"`
+ // the user's pronouns
+ Pronouns string `json:"pronouns"`
+ // the user's website
+ Website string `json:"website"`
+ // the user's description
+ Description string `json:"description"`
+ // User visibility level option: public, limited, private
+ Visibility string `json:"visibility"`
+
+ // user counts
+ Followers int `json:"followers_count"`
+ Following int `json:"following_count"`
+ StarredRepos int `json:"starred_repos_count"`
+}
+
+// MarshalJSON implements the json.Marshaler interface for User, adding field(s) for backward compatibility
+func (u User) MarshalJSON() ([]byte, error) {
+ // Redeclaring User to avoid recursion
+ type shadow User
+ return json.Marshal(struct {
+ shadow
+ CompatUserName string `json:"username"`
+ }{shadow(u), u.UserName})
+}
+
+// UserSettings represents user settings
+// swagger:model
+type UserSettings struct {
+ FullName string `json:"full_name"`
+ Website string `json:"website"`
+ Description string `json:"description"`
+ Location string `json:"location"`
+ Pronouns string `json:"pronouns"`
+ Language string `json:"language"`
+ Theme string `json:"theme"`
+ DiffViewStyle string `json:"diff_view_style"`
+ EnableRepoUnitHints bool `json:"enable_repo_unit_hints"`
+ // Privacy
+ HideEmail bool `json:"hide_email"`
+ HideActivity bool `json:"hide_activity"`
+}
+
+// UserSettingsOptions represents options to change user settings
+// swagger:model
+type UserSettingsOptions struct {
+ FullName *string `json:"full_name" binding:"MaxSize(100)"`
+ Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
+ Description *string `json:"description" binding:"MaxSize(255)"`
+ Location *string `json:"location" binding:"MaxSize(50)"`
+ Pronouns *string `json:"pronouns" binding:"MaxSize(50)"`
+ Language *string `json:"language"`
+ Theme *string `json:"theme"`
+ DiffViewStyle *string `json:"diff_view_style"`
+ EnableRepoUnitHints *bool `json:"enable_repo_unit_hints"`
+ // Privacy
+ HideEmail *bool `json:"hide_email"`
+ HideActivity *bool `json:"hide_activity"`
+}
+
+// RenameUserOption options when renaming a user
+type RenameUserOption struct {
+ // New username for this user. This name cannot be in use yet by any other user.
+ //
+ // required: true
+ // unique: true
+ NewName string `json:"new_username" binding:"Required"`
+}
+
+// UpdateUserAvatarUserOption options when updating the user avatar
+type UpdateUserAvatarOption struct {
+ // image must be base64 encoded
+ Image string `json:"image" binding:"Required"`
+}
diff --git a/modules/structs/user_app.go b/modules/structs/user_app.go
new file mode 100644
index 0000000..7f78fbd
--- /dev/null
+++ b/modules/structs/user_app.go
@@ -0,0 +1,53 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// AccessToken represents an API access token.
+// swagger:response AccessToken
+type AccessToken struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Token string `json:"sha1"`
+ TokenLastEight string `json:"token_last_eight"`
+ Scopes []string `json:"scopes"`
+}
+
+// AccessTokenList represents a list of API access token.
+// swagger:response AccessTokenList
+type AccessTokenList []*AccessToken
+
+// CreateAccessTokenOption options when create access token
+type CreateAccessTokenOption struct {
+ // required: true
+ Name string `json:"name" binding:"Required"`
+ Scopes []string `json:"scopes"`
+}
+
+// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
+type CreateOAuth2ApplicationOptions struct {
+ Name string `json:"name" binding:"Required"`
+ ConfidentialClient bool `json:"confidential_client"`
+ RedirectURIs []string `json:"redirect_uris" binding:"Required"`
+}
+
+// OAuth2Application represents an OAuth2 application.
+// swagger:response OAuth2Application
+type OAuth2Application struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ ConfidentialClient bool `json:"confidential_client"`
+ RedirectURIs []string `json:"redirect_uris"`
+ Created time.Time `json:"created"`
+}
+
+// OAuth2ApplicationList represents a list of OAuth2 applications.
+// swagger:response OAuth2ApplicationList
+type OAuth2ApplicationList []*OAuth2Application
diff --git a/modules/structs/user_email.go b/modules/structs/user_email.go
new file mode 100644
index 0000000..9319667
--- /dev/null
+++ b/modules/structs/user_email.go
@@ -0,0 +1,27 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// Email an email address belonging to a user
+type Email struct {
+ // swagger:strfmt email
+ Email string `json:"email"`
+ Verified bool `json:"verified"`
+ Primary bool `json:"primary"`
+ UserID int64 `json:"user_id"`
+ UserName string `json:"username"`
+}
+
+// CreateEmailOption options when creating email addresses
+type CreateEmailOption struct {
+ // email addresses to add
+ Emails []string `json:"emails"`
+}
+
+// DeleteEmailOption options when deleting email addresses
+type DeleteEmailOption struct {
+ // email addresses to delete
+ Emails []string `json:"emails"`
+}
diff --git a/modules/structs/user_gpgkey.go b/modules/structs/user_gpgkey.go
new file mode 100644
index 0000000..ff9b0ae
--- /dev/null
+++ b/modules/structs/user_gpgkey.go
@@ -0,0 +1,53 @@
+// Copyright 2017 Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// GPGKey a user GPG key to sign commit and tag in repository
+type GPGKey struct {
+ ID int64 `json:"id"`
+ PrimaryKeyID string `json:"primary_key_id"`
+ KeyID string `json:"key_id"`
+ PublicKey string `json:"public_key"`
+ Emails []*GPGKeyEmail `json:"emails"`
+ SubsKey []*GPGKey `json:"subkeys"`
+ CanSign bool `json:"can_sign"`
+ CanEncryptComms bool `json:"can_encrypt_comms"`
+ CanEncryptStorage bool `json:"can_encrypt_storage"`
+ CanCertify bool `json:"can_certify"`
+ Verified bool `json:"verified"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at,omitempty"`
+ // swagger:strfmt date-time
+ Expires time.Time `json:"expires_at,omitempty"`
+}
+
+// GPGKeyEmail an email attached to a GPGKey
+// swagger:model GPGKeyEmail
+type GPGKeyEmail struct {
+ Email string `json:"email"`
+ Verified bool `json:"verified"`
+}
+
+// CreateGPGKeyOption options create user GPG key
+type CreateGPGKeyOption struct {
+ // An armored GPG key to add
+ //
+ // required: true
+ // unique: true
+ ArmoredKey string `json:"armored_public_key" binding:"Required"`
+ Signature string `json:"armored_signature,omitempty"`
+}
+
+// VerifyGPGKeyOption options verifies user GPG key
+type VerifyGPGKeyOption struct {
+ // An Signature for a GPG key token
+ //
+ // required: true
+ KeyID string `json:"key_id" binding:"Required"`
+ Signature string `json:"armored_signature" binding:"Required"`
+}
diff --git a/modules/structs/user_key.go b/modules/structs/user_key.go
new file mode 100644
index 0000000..08eed59
--- /dev/null
+++ b/modules/structs/user_key.go
@@ -0,0 +1,22 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+ "time"
+)
+
+// PublicKey publickey is a user key to push code to repository
+type PublicKey struct {
+ ID int64 `json:"id"`
+ Key string `json:"key"`
+ URL string `json:"url,omitempty"`
+ Title string `json:"title,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ // swagger:strfmt date-time
+ Created time.Time `json:"created_at,omitempty"`
+ Owner *User `json:"user,omitempty"`
+ ReadOnly bool `json:"read_only,omitempty"`
+ KeyType string `json:"key_type,omitempty"`
+}
diff --git a/modules/structs/variable.go b/modules/structs/variable.go
new file mode 100644
index 0000000..cc846cf
--- /dev/null
+++ b/modules/structs/variable.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// CreateVariableOption the option when creating variable
+// swagger:model
+type CreateVariableOption struct {
+ // Value of the variable to create
+ //
+ // required: true
+ Value string `json:"value" binding:"Required"`
+}
+
+// UpdateVariableOption the option when updating variable
+// swagger:model
+type UpdateVariableOption struct {
+ // New name for the variable. If the field is empty, the variable name won't be updated.
+ Name string `json:"name"`
+ // Value of the variable to update
+ //
+ // required: true
+ Value string `json:"value" binding:"Required"`
+}
+
+// ActionVariable return value of the query API
+// swagger:model
+type ActionVariable struct {
+ // the owner to which the variable belongs
+ OwnerID int64 `json:"owner_id"`
+ // the repository to which the variable belongs
+ RepoID int64 `json:"repo_id"`
+ // the name of the variable
+ Name string `json:"name"`
+ // the value of the variable
+ Data string `json:"data"`
+}
diff --git a/modules/structs/visible_type.go b/modules/structs/visible_type.go
new file mode 100644
index 0000000..b5ff353
--- /dev/null
+++ b/modules/structs/visible_type.go
@@ -0,0 +1,58 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// VisibleType defines the visibility of user and org
+type VisibleType int
+
+const (
+ // VisibleTypePublic Visible for everyone
+ VisibleTypePublic VisibleType = iota
+
+ // VisibleTypeLimited Visible for every connected user
+ VisibleTypeLimited
+
+ // VisibleTypePrivate Visible only for self or admin user
+ VisibleTypePrivate
+)
+
+// VisibilityModes is a map of Visibility types
+var VisibilityModes = map[string]VisibleType{
+ "public": VisibleTypePublic,
+ "limited": VisibleTypeLimited,
+ "private": VisibleTypePrivate,
+}
+
+// IsPublic returns true if VisibleType is public
+func (vt VisibleType) IsPublic() bool {
+ return vt == VisibleTypePublic
+}
+
+// IsLimited returns true if VisibleType is limited
+func (vt VisibleType) IsLimited() bool {
+ return vt == VisibleTypeLimited
+}
+
+// IsPrivate returns true if VisibleType is private
+func (vt VisibleType) IsPrivate() bool {
+ return vt == VisibleTypePrivate
+}
+
+// VisibilityString provides the mode string of the visibility type (public, limited, private)
+func (vt VisibleType) String() string {
+ for k, v := range VisibilityModes {
+ if vt == v {
+ return k
+ }
+ }
+ return ""
+}
+
+// ExtractKeysFromMapString provides a slice of keys from map
+func ExtractKeysFromMapString(in map[string]VisibleType) (keys []string) {
+ for k := range in {
+ keys = append(keys, k)
+ }
+ return keys
+}
diff --git a/modules/structs/workflow.go b/modules/structs/workflow.go
new file mode 100644
index 0000000..c4429ea
--- /dev/null
+++ b/modules/structs/workflow.go
@@ -0,0 +1,15 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+// DispatchWorkflowOption options when dispatching a workflow
+// swagger:model
+type DispatchWorkflowOption struct {
+ // Git reference for the workflow
+ //
+ // required: true
+ Ref string `json:"ref"`
+ // Input keys and values configured in the workflow file.
+ Inputs map[string]string `json:"inputs"`
+}
diff --git a/modules/svg/processor.go b/modules/svg/processor.go
new file mode 100644
index 0000000..82248fb
--- /dev/null
+++ b/modules/svg/processor.go
@@ -0,0 +1,59 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package svg
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+ "sync"
+)
+
+type normalizeVarsStruct struct {
+ reXMLDoc,
+ reComment,
+ reAttrXMLNs,
+ reAttrSize,
+ reAttrClassPrefix *regexp.Regexp
+}
+
+var (
+ normalizeVars *normalizeVarsStruct
+ normalizeVarsOnce sync.Once
+)
+
+// Normalize normalizes the SVG content: set default width/height, remove unnecessary tags/attributes
+// It's designed to work with valid SVG content. For invalid SVG content, the returned content is not guaranteed.
+func Normalize(data []byte, size int) []byte {
+ normalizeVarsOnce.Do(func() {
+ normalizeVars = &normalizeVarsStruct{
+ reXMLDoc: regexp.MustCompile(`(?s)<\?xml.*?>`),
+ reComment: regexp.MustCompile(`(?s)<!--.*?-->`),
+
+ reAttrXMLNs: regexp.MustCompile(`(?s)\s+xmlns\s*=\s*"[^"]*"`),
+ reAttrSize: regexp.MustCompile(`(?s)\s+(width|height)\s*=\s*"[^"]+"`),
+ reAttrClassPrefix: regexp.MustCompile(`(?s)\s+class\s*=\s*"`),
+ }
+ })
+ data = normalizeVars.reXMLDoc.ReplaceAll(data, nil)
+ data = normalizeVars.reComment.ReplaceAll(data, nil)
+
+ data = bytes.TrimSpace(data)
+ svgTag, svgRemaining, ok := bytes.Cut(data, []byte(">"))
+ if !ok || !bytes.HasPrefix(svgTag, []byte(`<svg`)) {
+ return data
+ }
+ normalized := bytes.Clone(svgTag)
+ normalized = normalizeVars.reAttrXMLNs.ReplaceAll(normalized, nil)
+ normalized = normalizeVars.reAttrSize.ReplaceAll(normalized, nil)
+ normalized = normalizeVars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
+ normalized = bytes.TrimSpace(normalized)
+ normalized = fmt.Appendf(normalized, ` width="%d" height="%d"`, size, size)
+ if !bytes.Contains(normalized, []byte(` class="`)) {
+ normalized = append(normalized, ` class="svg"`...)
+ }
+ normalized = append(normalized, '>')
+ normalized = append(normalized, svgRemaining...)
+ return normalized
+}
diff --git a/modules/svg/processor_test.go b/modules/svg/processor_test.go
new file mode 100644
index 0000000..a028666
--- /dev/null
+++ b/modules/svg/processor_test.go
@@ -0,0 +1,29 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package svg
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNormalize(t *testing.T) {
+ res := Normalize([]byte("foo"), 1)
+ assert.Equal(t, "foo", string(res))
+
+ res = Normalize([]byte(`<?xml version="1.0"?>
+<!--
+comment
+-->
+<svg xmlns = "...">content</svg>`), 1)
+ assert.Equal(t, `<svg width="1" height="1" class="svg">content</svg>`, string(res))
+
+ res = Normalize([]byte(`<svg
+width="100"
+class="svg-icon"
+>content</svg>`), 16)
+
+ assert.Equal(t, `<svg class="svg-icon" width="16" height="16">content</svg>`, string(res))
+}
diff --git a/modules/svg/svg.go b/modules/svg/svg.go
new file mode 100644
index 0000000..016e1dc
--- /dev/null
+++ b/modules/svg/svg.go
@@ -0,0 +1,59 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package svg
+
+import (
+ "fmt"
+ "html/template"
+ "path"
+ "strings"
+
+ gitea_html "code.gitea.io/gitea/modules/html"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/public"
+)
+
+var svgIcons map[string]string
+
+const defaultSize = 16
+
+// Init discovers SVG icons and populates the `svgIcons` variable
+func Init() error {
+ const svgAssetsPath = "assets/img/svg"
+ files, err := public.AssetFS().ListFiles(svgAssetsPath)
+ if err != nil {
+ return err
+ }
+
+ svgIcons = make(map[string]string, len(files))
+ for _, file := range files {
+ if path.Ext(file) != ".svg" {
+ continue
+ }
+ bs, err := public.AssetFS().ReadFile(svgAssetsPath, file)
+ if err != nil {
+ log.Error("Failed to read SVG file %s: %v", file, err)
+ } else {
+ svgIcons[file[:len(file)-4]] = string(Normalize(bs, defaultSize))
+ }
+ }
+ return nil
+}
+
+// RenderHTML renders icons - arguments icon name (string), size (int), class (string)
+func RenderHTML(icon string, others ...any) template.HTML {
+ size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
+ if svgStr, ok := svgIcons[icon]; ok {
+ // the code is somewhat hacky, but it just works, because the SVG contents are all normalized
+ if size != defaultSize {
+ svgStr = strings.Replace(svgStr, fmt.Sprintf(`width="%d"`, defaultSize), fmt.Sprintf(`width="%d"`, size), 1)
+ svgStr = strings.Replace(svgStr, fmt.Sprintf(`height="%d"`, defaultSize), fmt.Sprintf(`height="%d"`, size), 1)
+ }
+ if class != "" {
+ svgStr = strings.Replace(svgStr, `class="`, fmt.Sprintf(`class="%s `, class), 1)
+ }
+ return template.HTML(svgStr)
+ }
+ return ""
+}
diff --git a/modules/sync/exclusive_pool.go b/modules/sync/exclusive_pool.go
new file mode 100644
index 0000000..fbfc1f2
--- /dev/null
+++ b/modules/sync/exclusive_pool.go
@@ -0,0 +1,69 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sync
+
+import (
+ "sync"
+)
+
+// ExclusivePool is a pool of non-identical instances
+// that only one instance with same identity is in the pool at a time.
+// In other words, only instances with different identities can be in
+// the pool the same time. If another instance with same identity tries
+// to get into the pool, it hangs until previous instance left the pool.
+//
+// This pool is particularly useful for performing tasks on same resource
+// on the file system in different goroutines.
+type ExclusivePool struct {
+ lock sync.Mutex
+
+ // pool maintains locks for each instance in the pool.
+ pool map[string]*sync.Mutex
+
+ // count maintains the number of times an instance with same identity checks in
+ // to the pool, and should be reduced to 0 (removed from map) by checking out
+ // with same number of times.
+ // The purpose of count is to delete lock when count down to 0 and recycle memory
+ // from map object.
+ count map[string]int
+}
+
+// NewExclusivePool initializes and returns a new ExclusivePool object.
+func NewExclusivePool() *ExclusivePool {
+ return &ExclusivePool{
+ pool: make(map[string]*sync.Mutex),
+ count: make(map[string]int),
+ }
+}
+
+// CheckIn checks in an instance to the pool and hangs while instance
+// with same identity is using the lock.
+func (p *ExclusivePool) CheckIn(identity string) {
+ p.lock.Lock()
+
+ lock, has := p.pool[identity]
+ if !has {
+ lock = &sync.Mutex{}
+ p.pool[identity] = lock
+ }
+ p.count[identity]++
+
+ p.lock.Unlock()
+ lock.Lock()
+}
+
+// CheckOut checks out an instance from the pool and releases the lock
+// to let other instances with same identity to grab the lock.
+func (p *ExclusivePool) CheckOut(identity string) {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ p.pool[identity].Unlock()
+ if p.count[identity] == 1 {
+ delete(p.pool, identity)
+ delete(p.count, identity)
+ } else {
+ p.count[identity]--
+ }
+}
diff --git a/modules/sync/status_pool.go b/modules/sync/status_pool.go
new file mode 100644
index 0000000..6f075d5
--- /dev/null
+++ b/modules/sync/status_pool.go
@@ -0,0 +1,57 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sync
+
+import (
+ "sync"
+
+ "code.gitea.io/gitea/modules/container"
+)
+
+// StatusTable is a table maintains true/false values.
+//
+// This table is particularly useful for un/marking and checking values
+// in different goroutines.
+type StatusTable struct {
+ lock sync.RWMutex
+ pool container.Set[string]
+}
+
+// NewStatusTable initializes and returns a new StatusTable object.
+func NewStatusTable() *StatusTable {
+ return &StatusTable{
+ pool: make(container.Set[string]),
+ }
+}
+
+// StartIfNotRunning sets value of given name to true if not already in pool.
+// Returns whether set value was set to true
+func (p *StatusTable) StartIfNotRunning(name string) bool {
+ p.lock.Lock()
+ added := p.pool.Add(name)
+ p.lock.Unlock()
+ return added
+}
+
+// Start sets value of given name to true in the pool.
+func (p *StatusTable) Start(name string) {
+ p.lock.Lock()
+ p.pool.Add(name)
+ p.lock.Unlock()
+}
+
+// Stop sets value of given name to false in the pool.
+func (p *StatusTable) Stop(name string) {
+ p.lock.Lock()
+ p.pool.Remove(name)
+ p.lock.Unlock()
+}
+
+// IsRunning checks if value of given name is set to true in the pool.
+func (p *StatusTable) IsRunning(name string) bool {
+ p.lock.RLock()
+ exists := p.pool.Contains(name)
+ p.lock.RUnlock()
+ return exists
+}
diff --git a/modules/sync/status_pool_test.go b/modules/sync/status_pool_test.go
new file mode 100644
index 0000000..e2e4886
--- /dev/null
+++ b/modules/sync/status_pool_test.go
@@ -0,0 +1,31 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sync
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_StatusTable(t *testing.T) {
+ table := NewStatusTable()
+
+ assert.False(t, table.IsRunning("xyz"))
+
+ table.Start("xyz")
+ assert.True(t, table.IsRunning("xyz"))
+
+ assert.False(t, table.StartIfNotRunning("xyz"))
+ assert.True(t, table.IsRunning("xyz"))
+
+ table.Stop("xyz")
+ assert.False(t, table.IsRunning("xyz"))
+
+ assert.True(t, table.StartIfNotRunning("xyz"))
+ assert.True(t, table.IsRunning("xyz"))
+
+ table.Stop("xyz")
+ assert.False(t, table.IsRunning("xyz"))
+}
diff --git a/modules/system/appstate.go b/modules/system/appstate.go
new file mode 100644
index 0000000..e065688
--- /dev/null
+++ b/modules/system/appstate.go
@@ -0,0 +1,26 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package system
+
+import "context"
+
+// StateStore is the interface to get/set app state items
+type StateStore interface {
+ Get(ctx context.Context, item StateItem) error
+ Set(ctx context.Context, item StateItem) error
+}
+
+// StateItem provides the name for a state item. the name will be used to generate filenames, etc
+type StateItem interface {
+ Name() string
+}
+
+// AppState contains the state items for the app
+var AppState StateStore
+
+// Init initialize AppState interface
+func Init() error {
+ AppState = &DBStore{}
+ return nil
+}
diff --git a/modules/system/appstate_test.go b/modules/system/appstate_test.go
new file mode 100644
index 0000000..2f44c7b
--- /dev/null
+++ b/modules/system/appstate_test.go
@@ -0,0 +1,66 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package system
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{
+ FixtureFiles: []string{""}, // load nothing
+ })
+}
+
+type testItem1 struct {
+ Val1 string
+ Val2 int
+}
+
+func (*testItem1) Name() string {
+ return "test-item1"
+}
+
+type testItem2 struct {
+ K string
+}
+
+func (*testItem2) Name() string {
+ return "test-item2"
+}
+
+func TestAppStateDB(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ as := &DBStore{}
+
+ item1 := new(testItem1)
+ require.NoError(t, as.Get(db.DefaultContext, item1))
+ assert.Equal(t, "", item1.Val1)
+ assert.EqualValues(t, 0, item1.Val2)
+
+ item1 = new(testItem1)
+ item1.Val1 = "a"
+ item1.Val2 = 2
+ require.NoError(t, as.Set(db.DefaultContext, item1))
+
+ item2 := new(testItem2)
+ item2.K = "V"
+ require.NoError(t, as.Set(db.DefaultContext, item2))
+
+ item1 = new(testItem1)
+ require.NoError(t, as.Get(db.DefaultContext, item1))
+ assert.Equal(t, "a", item1.Val1)
+ assert.EqualValues(t, 2, item1.Val2)
+
+ item2 = new(testItem2)
+ require.NoError(t, as.Get(db.DefaultContext, item2))
+ assert.Equal(t, "V", item2.K)
+}
diff --git a/modules/system/db.go b/modules/system/db.go
new file mode 100644
index 0000000..1717828
--- /dev/null
+++ b/modules/system/db.go
@@ -0,0 +1,36 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package system
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/system"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// DBStore can be used to store app state items in local filesystem
+type DBStore struct{}
+
+// Get reads the state item
+func (f *DBStore) Get(ctx context.Context, item StateItem) error {
+ content, err := system.GetAppStateContent(ctx, item.Name())
+ if err != nil {
+ return err
+ }
+ if content == "" {
+ return nil
+ }
+ return json.Unmarshal(util.UnsafeStringToBytes(content), item)
+}
+
+// Set saves the state item
+func (f *DBStore) Set(ctx context.Context, item StateItem) error {
+ b, err := json.Marshal(item)
+ if err != nil {
+ return err
+ }
+ return system.SaveAppStateContent(ctx, item.Name(), util.UnsafeBytesToString(b))
+}
diff --git a/modules/system/item_runtime.go b/modules/system/item_runtime.go
new file mode 100644
index 0000000..ded52c1
--- /dev/null
+++ b/modules/system/item_runtime.go
@@ -0,0 +1,15 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package system
+
+// RuntimeState contains app state for runtime, and we can save remote version for update checker here in future
+type RuntimeState struct {
+ LastAppPath string `json:"last_app_path"`
+ LastCustomConf string `json:"last_custom_conf"`
+}
+
+// Name returns the item name
+func (a RuntimeState) Name() string {
+ return "runtime-state"
+}
diff --git a/modules/templates/base.go b/modules/templates/base.go
new file mode 100644
index 0000000..2c2f35b
--- /dev/null
+++ b/modules/templates/base.go
@@ -0,0 +1,40 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func AssetFS() *assetfs.LayeredFS {
+ return assetfs.Layered(CustomAssets(), BuiltinAssets())
+}
+
+func CustomAssets() *assetfs.Layer {
+ return assetfs.Local("custom", setting.CustomPath, "templates")
+}
+
+func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
+ files, err := assets.ListAllFiles(".", true)
+ if err != nil {
+ return nil, err
+ }
+ return slices.DeleteFunc(files, func(file string) bool {
+ return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
+ }), nil
+}
+
+func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
+ files, err := assets.ListAllFiles(".", true)
+ if err != nil {
+ return nil, err
+ }
+ return slices.DeleteFunc(files, func(file string) bool {
+ return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
+ }), nil
+}
diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go
new file mode 100644
index 0000000..e1babd8
--- /dev/null
+++ b/modules/templates/dynamic.go
@@ -0,0 +1,15 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package templates
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Local("builtin(static)", setting.StaticRootPath, "templates")
+}
diff --git a/modules/templates/eval/eval.go b/modules/templates/eval/eval.go
new file mode 100644
index 0000000..5d4ac91
--- /dev/null
+++ b/modules/templates/eval/eval.go
@@ -0,0 +1,344 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eval
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+type Num struct {
+ Value any // int64 or float64, nil on error
+}
+
+var opPrecedence = map[string]int{
+ // "(": 1, this is for low precedence like function calls, they are handled separately
+ "or": 2,
+ "and": 3,
+ "not": 4,
+ "==": 5, "!=": 5, "<": 5, "<=": 5, ">": 5, ">=": 5,
+ "+": 6, "-": 6,
+ "*": 7, "/": 7,
+}
+
+type stack[T any] struct {
+ name string
+ elems []T
+}
+
+func (s *stack[T]) push(t T) {
+ s.elems = append(s.elems, t)
+}
+
+func (s *stack[T]) pop() T {
+ if len(s.elems) == 0 {
+ panic(s.name + " stack is empty")
+ }
+ t := s.elems[len(s.elems)-1]
+ s.elems = s.elems[:len(s.elems)-1]
+ return t
+}
+
+func (s *stack[T]) peek() T {
+ if len(s.elems) == 0 {
+ panic(s.name + " stack is empty")
+ }
+ return s.elems[len(s.elems)-1]
+}
+
+type operator string
+
+type eval struct {
+ stackNum stack[Num]
+ stackOp stack[operator]
+ funcMap map[string]func([]Num) Num
+}
+
+func newEval() *eval {
+ e := &eval{}
+ e.stackNum.name = "num"
+ e.stackOp.name = "op"
+ return e
+}
+
+func toNum(v any) (Num, error) {
+ switch v := v.(type) {
+ case string:
+ if strings.Contains(v, ".") {
+ n, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ }
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ case float32, float64:
+ n, _ := util.ToFloat64(v)
+ return Num{n}, nil
+ default:
+ n, err := util.ToInt64(v)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ }
+}
+
+func truth(b bool) int64 {
+ if b {
+ return int64(1)
+ }
+ return int64(0)
+}
+
+func applyOp2Generic[T int64 | float64](op operator, n1, n2 T) Num {
+ switch op {
+ case "+":
+ return Num{n1 + n2}
+ case "-":
+ return Num{n1 - n2}
+ case "*":
+ return Num{n1 * n2}
+ case "/":
+ return Num{n1 / n2}
+ case "==":
+ return Num{truth(n1 == n2)}
+ case "!=":
+ return Num{truth(n1 != n2)}
+ case "<":
+ return Num{truth(n1 < n2)}
+ case "<=":
+ return Num{truth(n1 <= n2)}
+ case ">":
+ return Num{truth(n1 > n2)}
+ case ">=":
+ return Num{truth(n1 >= n2)}
+ case "and":
+ t1, _ := util.ToFloat64(n1)
+ t2, _ := util.ToFloat64(n2)
+ return Num{truth(t1 != 0 && t2 != 0)}
+ case "or":
+ t1, _ := util.ToFloat64(n1)
+ t2, _ := util.ToFloat64(n2)
+ return Num{truth(t1 != 0 || t2 != 0)}
+ }
+ panic("unknown operator: " + string(op))
+}
+
+func applyOp2(op operator, n1, n2 Num) Num {
+ float := false
+ if _, ok := n1.Value.(float64); ok {
+ float = true
+ } else if _, ok = n2.Value.(float64); ok {
+ float = true
+ }
+ if float {
+ f1, _ := util.ToFloat64(n1.Value)
+ f2, _ := util.ToFloat64(n2.Value)
+ return applyOp2Generic(op, f1, f2)
+ }
+ return applyOp2Generic(op, n1.Value.(int64), n2.Value.(int64))
+}
+
+func toOp(v any) (operator, error) {
+ if v, ok := v.(string); ok {
+ return operator(v), nil
+ }
+ return "", fmt.Errorf(`unsupported token type "%T"`, v)
+}
+
+func (op operator) hasOpenBracket() bool {
+ return strings.HasSuffix(string(op), "(") // it's used to support functions like "sum("
+}
+
+func (op operator) isComma() bool {
+ return op == ","
+}
+
+func (op operator) isCloseBracket() bool {
+ return op == ")"
+}
+
+type ExprError struct {
+ msg string
+ tokens []any
+ err error
+}
+
+func (err ExprError) Error() string {
+ sb := strings.Builder{}
+ sb.WriteString(err.msg)
+ sb.WriteString(" [ ")
+ for _, token := range err.tokens {
+ _, _ = fmt.Fprintf(&sb, `"%v" `, token)
+ }
+ sb.WriteString("]")
+ if err.err != nil {
+ sb.WriteString(": ")
+ sb.WriteString(err.err.Error())
+ }
+ return sb.String()
+}
+
+func (err ExprError) Unwrap() error {
+ return err.err
+}
+
+func (e *eval) applyOp() {
+ op := e.stackOp.pop()
+ if op == "not" {
+ num := e.stackNum.pop()
+ i, _ := util.ToInt64(num.Value)
+ e.stackNum.push(Num{truth(i == 0)})
+ } else if op.hasOpenBracket() || op.isCloseBracket() || op.isComma() {
+ panic(fmt.Sprintf("incomplete sub-expression with operator %q", op))
+ } else {
+ num2 := e.stackNum.pop()
+ num1 := e.stackNum.pop()
+ e.stackNum.push(applyOp2(op, num1, num2))
+ }
+}
+
+func (e *eval) exec(tokens ...any) (ret Num, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ rErr, ok := r.(error)
+ if !ok {
+ rErr = fmt.Errorf("%v", r)
+ }
+ err = ExprError{"invalid expression", tokens, rErr}
+ }
+ }()
+ for _, token := range tokens {
+ n, err := toNum(token)
+ if err == nil {
+ e.stackNum.push(n)
+ continue
+ }
+
+ op, err := toOp(token)
+ if err != nil {
+ return Num{}, ExprError{"invalid expression", tokens, err}
+ }
+
+ switch {
+ case op.hasOpenBracket():
+ e.stackOp.push(op)
+ case op.isCloseBracket(), op.isComma():
+ var stackTopOp operator
+ for len(e.stackOp.elems) > 0 {
+ stackTopOp = e.stackOp.peek()
+ if stackTopOp.hasOpenBracket() || stackTopOp.isComma() {
+ break
+ }
+ e.applyOp()
+ }
+ if op.isCloseBracket() {
+ nums := []Num{e.stackNum.pop()}
+ for !e.stackOp.peek().hasOpenBracket() {
+ stackTopOp = e.stackOp.pop()
+ if !stackTopOp.isComma() {
+ return Num{}, ExprError{"bracket doesn't match", tokens, nil}
+ }
+ nums = append(nums, e.stackNum.pop())
+ }
+ for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 {
+ nums[i], nums[j] = nums[j], nums[i] // reverse nums slice, to get the right order for arguments
+ }
+ stackTopOp = e.stackOp.pop()
+ fn := string(stackTopOp[:len(stackTopOp)-1])
+ if fn == "" {
+ if len(nums) != 1 {
+ return Num{}, ExprError{"too many values in one bracket", tokens, nil}
+ }
+ e.stackNum.push(nums[0])
+ } else if f, ok := e.funcMap[fn]; ok {
+ e.stackNum.push(f(nums))
+ } else {
+ return Num{}, ExprError{"unknown function: " + fn, tokens, nil}
+ }
+ } else {
+ e.stackOp.push(op)
+ }
+ default:
+ for len(e.stackOp.elems) > 0 && len(e.stackNum.elems) > 0 {
+ stackTopOp := e.stackOp.peek()
+ if stackTopOp.hasOpenBracket() || stackTopOp.isComma() || precedence(stackTopOp, op) < 0 {
+ break
+ }
+ e.applyOp()
+ }
+ e.stackOp.push(op)
+ }
+ }
+ for len(e.stackOp.elems) > 0 && !e.stackOp.peek().isComma() {
+ e.applyOp()
+ }
+ if len(e.stackNum.elems) != 1 {
+ return Num{}, ExprError{fmt.Sprintf("expect 1 value as final result, but there are %d", len(e.stackNum.elems)), tokens, nil}
+ }
+ return e.stackNum.pop(), nil
+}
+
+func precedence(op1, op2 operator) int {
+ p1 := opPrecedence[string(op1)]
+ p2 := opPrecedence[string(op2)]
+ if p1 == 0 {
+ panic("unknown operator precedence: " + string(op1))
+ } else if p2 == 0 {
+ panic("unknown operator precedence: " + string(op2))
+ }
+ return p1 - p2
+}
+
+func castFloat64(nums []Num) bool {
+ hasFloat := false
+ for _, num := range nums {
+ if _, hasFloat = num.Value.(float64); hasFloat {
+ break
+ }
+ }
+ if hasFloat {
+ for i, num := range nums {
+ if _, ok := num.Value.(float64); !ok {
+ f, _ := util.ToFloat64(num.Value)
+ nums[i] = Num{f}
+ }
+ }
+ }
+ return hasFloat
+}
+
+func fnSum(nums []Num) Num {
+ if castFloat64(nums) {
+ var sum float64
+ for _, num := range nums {
+ sum += num.Value.(float64)
+ }
+ return Num{sum}
+ }
+ var sum int64
+ for _, num := range nums {
+ sum += num.Value.(int64)
+ }
+ return Num{sum}
+}
+
+// Expr evaluates the given expression tokens and returns the result.
+// It supports the following operators: +, -, *, /, and, or, not, ==, !=, >, >=, <, <=.
+// Non-zero values are treated as true, zero values are treated as false.
+// If no error occurs, the result is either an int64 or a float64.
+// If all numbers are integer, the result is an int64, otherwise if there is any float number, the result is a float64.
+func Expr(tokens ...any) (Num, error) {
+ e := newEval()
+ e.funcMap = map[string]func([]Num) Num{"sum": fnSum}
+ return e.exec(tokens...)
+}
diff --git a/modules/templates/eval/eval_test.go b/modules/templates/eval/eval_test.go
new file mode 100644
index 0000000..3e68203
--- /dev/null
+++ b/modules/templates/eval/eval_test.go
@@ -0,0 +1,94 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eval
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func tokens(s string) (a []any) {
+ for _, v := range strings.Fields(s) {
+ a = append(a, v)
+ }
+ return a
+}
+
+func TestEval(t *testing.T) {
+ n, err := Expr(0, "/", 0.0)
+ require.NoError(t, err)
+ assert.True(t, math.IsNaN(n.Value.(float64)))
+
+ _, err = Expr(nil)
+ require.ErrorContains(t, err, "unsupported token type")
+ _, err = Expr([]string{})
+ require.ErrorContains(t, err, "unsupported token type")
+ _, err = Expr(struct{}{})
+ require.ErrorContains(t, err, "unsupported token type")
+
+ cases := []struct {
+ expr string
+ want any
+ }{
+ {"-1", int64(-1)},
+ {"1 + 2", int64(3)},
+ {"3 - 2 + 4", int64(5)},
+ {"1 + 2 * 3", int64(7)},
+ {"1 + ( 2 * 3 )", int64(7)},
+ {"( 1 + 2 ) * 3", int64(9)},
+ {"( 1 + 2.0 ) / 3", float64(1)},
+ {"sum( 1 , 2 , 3 , 4 )", int64(10)},
+ {"100 + sum( 1 , 2 + 3 , 0.0 ) / 2", float64(103)},
+ {"100 * 5 / ( 5 + 15 )", int64(25)},
+ {"9 == 5", int64(0)},
+ {"5 == 5", int64(1)},
+ {"9 != 5", int64(1)},
+ {"5 != 5", int64(0)},
+ {"9 > 5", int64(1)},
+ {"5 > 9", int64(0)},
+ {"5 >= 9", int64(0)},
+ {"9 >= 9", int64(1)},
+ {"9 < 5", int64(0)},
+ {"5 < 9", int64(1)},
+ {"9 <= 5", int64(0)},
+ {"5 <= 5", int64(1)},
+ {"1 and 2", int64(1)}, // Golang template definition: non-zero values are all truth
+ {"1 and 0", int64(0)},
+ {"0 and 0", int64(0)},
+ {"1 or 2", int64(1)},
+ {"1 or 0", int64(1)},
+ {"0 or 1", int64(1)},
+ {"0 or 0", int64(0)},
+ {"not 2 == 1", int64(1)},
+ {"not not ( 9 < 5 )", int64(0)},
+ }
+
+ for _, c := range cases {
+ n, err := Expr(tokens(c.expr)...)
+ require.NoError(t, err, "expr: %s", c.expr)
+ assert.Equal(t, c.want, n.Value)
+ }
+
+ bads := []struct {
+ expr string
+ errMsg string
+ }{
+ {"0 / 0", "integer divide by zero"},
+ {"1 +", "num stack is empty"},
+ {"+ 1", "num stack is empty"},
+ {"( 1", "incomplete sub-expression"},
+ {"1 )", "op stack is empty"}, // can not find the corresponding open bracket after the stack becomes empty
+ {"1 , 2", "expect 1 value as final result"},
+ {"( 1 , 2 )", "too many values in one bracket"},
+ {"1 a 2", "unknown operator"},
+ }
+ for _, c := range bads {
+ _, err = Expr(tokens(c.expr)...)
+ require.ErrorContains(t, err, c.errMsg, "expr: %s", c.expr)
+ }
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
new file mode 100644
index 0000000..aeae820
--- /dev/null
+++ b/modules/templates/helper.go
@@ -0,0 +1,269 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/svg"
+ "code.gitea.io/gitea/modules/templates/eval"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/gitdiff"
+)
+
+// NewFuncMap returns functions for injecting to templates
+func NewFuncMap() template.FuncMap {
+ return map[string]any{
+ "ctx": func() any { return nil }, // template context function
+
+ "DumpVar": dumpVar,
+
+ // -----------------------------------------------------------------
+ // html/template related functions
+ "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
+ "Eval": Eval,
+ "SafeHTML": SafeHTML,
+ "HTMLFormat": HTMLFormat,
+ "HTMLEscape": HTMLEscape,
+ "QueryEscape": QueryEscape,
+ "JSEscape": JSEscapeSafe,
+ "SanitizeHTML": SanitizeHTML,
+ "URLJoin": util.URLJoin,
+ "DotEscape": DotEscape,
+
+ "PathEscape": url.PathEscape,
+ "PathEscapeSegments": util.PathEscapeSegments,
+
+ // utils
+ "StringUtils": NewStringUtils,
+ "SliceUtils": NewSliceUtils,
+ "JsonUtils": NewJsonUtils,
+
+ // -----------------------------------------------------------------
+ // svg / avatar / icon / color
+ "svg": svg.RenderHTML,
+ "EntryIcon": base.EntryIcon,
+ "MigrationIcon": MigrationIcon,
+ "ActionIcon": ActionIcon,
+ "SortArrow": SortArrow,
+ "ContrastColor": util.ContrastColor,
+
+ // -----------------------------------------------------------------
+ // time / number / format
+ "FileSize": FileSizePanic,
+ "CountFmt": base.FormatNumberSI,
+ "TimeSince": timeutil.TimeSince,
+ "TimeSinceUnix": timeutil.TimeSinceUnix,
+ "DateTime": timeutil.DateTime,
+ "Sec2Time": util.SecToTime,
+ "LoadTimes": func(startTime time.Time) string {
+ return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
+ },
+
+ // -----------------------------------------------------------------
+ // setting
+ "AppName": func() string {
+ return setting.AppName
+ },
+ "AppSlogan": func() string {
+ return setting.AppSlogan
+ },
+ "AppDisplayName": func() string {
+ return setting.AppDisplayName
+ },
+ "AppSubUrl": func() string {
+ return setting.AppSubURL
+ },
+ "AssetUrlPrefix": func() string {
+ return setting.StaticURLPrefix + "/assets"
+ },
+ "AppUrl": func() string {
+ // The usage of AppUrl should be avoided as much as possible,
+ // because the AppURL(ROOT_URL) may not match user's visiting site and the ROOT_URL in app.ini may be incorrect.
+ // And it's difficult for Gitea to guess absolute URL correctly with zero configuration,
+ // because Gitea doesn't know whether the scheme is HTTP or HTTPS unless the reverse proxy could tell Gitea.
+ return setting.AppURL
+ },
+ "AppVer": func() string {
+ return setting.AppVer
+ },
+ "AppDomain": func() string { // documented in mail-templates.md
+ return setting.Domain
+ },
+ "RepoFlagsEnabled": func() bool {
+ return setting.Repository.EnableFlags
+ },
+ "AssetVersion": func() string {
+ return setting.AssetVersion
+ },
+ "DefaultShowFullName": func() bool {
+ return setting.UI.DefaultShowFullName
+ },
+ "ShowFooterTemplateLoadTime": func() bool {
+ return setting.Other.ShowFooterTemplateLoadTime
+ },
+ "ShowFooterPoweredBy": func() bool {
+ return setting.Other.ShowFooterPoweredBy
+ },
+ "AllowedReactions": func() []string {
+ return setting.UI.Reactions
+ },
+ "CustomEmojis": func() map[string]string {
+ return setting.UI.CustomEmojisMap
+ },
+ "MetaAuthor": func() string {
+ return setting.UI.Meta.Author
+ },
+ "MetaDescription": func() string {
+ return setting.UI.Meta.Description
+ },
+ "MetaKeywords": func() string {
+ return setting.UI.Meta.Keywords
+ },
+ "EnableTimetracking": func() bool {
+ return setting.Service.EnableTimetracking
+ },
+ "DisableGitHooks": func() bool {
+ return setting.DisableGitHooks
+ },
+ "DisableWebhooks": func() bool {
+ return setting.DisableWebhooks
+ },
+ "DisableImportLocal": func() bool {
+ return !setting.ImportLocalPaths
+ },
+ "ThemeName": func(user *user_model.User) string {
+ if user == nil || user.Theme == "" {
+ return setting.UI.DefaultTheme
+ }
+ return user.Theme
+ },
+ "NotificationSettings": func() map[string]any {
+ return map[string]any{
+ "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
+ "TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
+ "MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
+ "EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
+ }
+ },
+ "MermaidMaxSourceCharacters": func() int {
+ return setting.MermaidMaxSourceCharacters
+ },
+ "FederationEnabled": func() bool {
+ return setting.Federation.Enabled
+ },
+
+ // -----------------------------------------------------------------
+ // render
+ "RenderCommitMessage": RenderCommitMessage,
+ "RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
+
+ "RenderCommitBody": RenderCommitBody,
+ "RenderCodeBlock": RenderCodeBlock,
+ "RenderIssueTitle": RenderIssueTitle,
+ "RenderRefIssueTitle": RenderRefIssueTitle,
+ "RenderEmoji": RenderEmoji,
+ "ReactionToEmoji": ReactionToEmoji,
+
+ "RenderMarkdownToHtml": RenderMarkdownToHtml,
+ "RenderLabel": RenderLabel,
+ "RenderLabels": RenderLabels,
+
+ // -----------------------------------------------------------------
+ // misc
+ "ShortSha": base.ShortSha,
+ "ActionContent2Commits": ActionContent2Commits,
+ "IsMultilineCommitMessage": IsMultilineCommitMessage,
+ "CommentMustAsDiff": gitdiff.CommentMustAsDiff,
+ "MirrorRemoteAddress": mirrorRemoteAddress,
+
+ "FilenameIsImage": FilenameIsImage,
+ "TabSizeClass": TabSizeClass,
+ }
+}
+
+func HTMLFormat(s string, rawArgs ...any) template.HTML {
+ args := slices.Clone(rawArgs)
+ for i, v := range args {
+ switch v := v.(type) {
+ case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
+ // for most basic types (including template.HTML which is safe), just do nothing and use it
+ case string:
+ args[i] = template.HTMLEscapeString(v)
+ case fmt.Stringer:
+ args[i] = template.HTMLEscapeString(v.String())
+ default:
+ args[i] = template.HTMLEscapeString(fmt.Sprint(v))
+ }
+ }
+ return template.HTML(fmt.Sprintf(s, args...))
+}
+
+// SafeHTML render raw as HTML
+func SafeHTML(s any) template.HTML {
+ switch v := s.(type) {
+ case string:
+ return template.HTML(v)
+ case template.HTML:
+ return v
+ }
+ panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+// SanitizeHTML sanitizes the input by pre-defined markdown rules
+func SanitizeHTML(s string) template.HTML {
+ return template.HTML(markup.Sanitize(s))
+}
+
+func HTMLEscape(s any) template.HTML {
+ switch v := s.(type) {
+ case string:
+ return template.HTML(html.EscapeString(v))
+ case template.HTML:
+ return v
+ }
+ panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+func JSEscapeSafe(s string) template.HTML {
+ return template.HTML(template.JSEscapeString(s))
+}
+
+func QueryEscape(s string) template.URL {
+ return template.URL(url.QueryEscape(s))
+}
+
+// DotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent autolinkers from detecting these as urls
+func DotEscape(raw string) string {
+ return strings.ReplaceAll(raw, ".", "\u200d.\u200d")
+}
+
+// Eval the expression and return the result, see the comment of eval.Expr for details.
+// To use this helper function in templates, pass each token as a separate parameter.
+//
+// {{ $int64 := Eval $var "+" 1 }}
+// {{ $float64 := Eval $var "+" 1.0 }}
+//
+// Golang's template supports comparable int types, so the int64 result can be used in later statements like {{if lt $int64 10}}
+func Eval(tokens ...any) (any, error) {
+ n, err := eval.Expr(tokens...)
+ return n.Value, err
+}
+
+func FileSizePanic(s int64) string {
+ panic("Usage of FileSize in templates is deprecated in Forgejo. Locale.TrSize should be used instead.")
+}
diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go
new file mode 100644
index 0000000..0cefb7a
--- /dev/null
+++ b/modules/templates/helper_test.go
@@ -0,0 +1,67 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSubjectBodySeparator(t *testing.T) {
+ test := func(input, subject, body string) {
+ loc := mailSubjectSplit.FindIndex([]byte(input))
+ if loc == nil {
+ assert.Empty(t, subject, "no subject found, but one expected")
+ assert.Equal(t, body, input)
+ } else {
+ assert.Equal(t, subject, input[0:loc[0]])
+ assert.Equal(t, body, input[loc[1]:])
+ }
+ }
+
+ test("Simple\n---------------\nCase",
+ "Simple\n",
+ "\nCase")
+ test("Only\nBody",
+ "",
+ "Only\nBody")
+ test("Minimal\n---\nseparator",
+ "Minimal\n",
+ "\nseparator")
+ test("False --- separator",
+ "",
+ "False --- separator")
+ test("False\n--- separator",
+ "",
+ "False\n--- separator")
+ test("False ---\nseparator",
+ "",
+ "False ---\nseparator")
+ test("With extra spaces\n----- \t \nBody",
+ "With extra spaces\n",
+ "\nBody")
+ test("With leading spaces\n -------\nOnly body",
+ "",
+ "With leading spaces\n -------\nOnly body")
+ test("Multiple\n---\n-------\n---\nSeparators",
+ "Multiple\n",
+ "\n-------\n---\nSeparators")
+ test("Insufficient\n--\nSeparators",
+ "",
+ "Insufficient\n--\nSeparators")
+}
+
+func TestJSEscapeSafe(t *testing.T) {
+ assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, JSEscapeSafe(`&<>'"`))
+}
+
+func TestHTMLFormat(t *testing.T) {
+ assert.Equal(t, template.HTML("<a>&lt; < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1))
+}
+
+func TestSanitizeHTML(t *testing.T) {
+ assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
+}
diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go
new file mode 100644
index 0000000..55a55dd
--- /dev/null
+++ b/modules/templates/htmlrenderer.go
@@ -0,0 +1,287 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ texttemplate "text/template"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates/scopedtmpl"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type TemplateExecutor scopedtmpl.TemplateExecutor
+
+type HTMLRender struct {
+ templates atomic.Pointer[scopedtmpl.ScopedTemplate]
+}
+
+var (
+ htmlRender *HTMLRender
+ htmlRenderOnce sync.Once
+)
+
+var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
+
+func (h *HTMLRender) HTML(w io.Writer, status int, name string, data any, ctx context.Context) error { //nolint:revive
+ if respWriter, ok := w.(http.ResponseWriter); ok {
+ if respWriter.Header().Get("Content-Type") == "" {
+ respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
+ }
+ respWriter.WriteHeader(status)
+ }
+ t, err := h.TemplateLookup(name, ctx)
+ if err != nil {
+ return texttemplate.ExecError{Name: name, Err: err}
+ }
+ return t.Execute(w, data)
+}
+
+func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive
+ tmpls := h.templates.Load()
+ if tmpls == nil {
+ return nil, ErrTemplateNotInitialized
+ }
+ m := NewFuncMap()
+ m["ctx"] = func() any { return ctx }
+ return tmpls.Executor(name, m)
+}
+
+func (h *HTMLRender) CompileTemplates() error {
+ assets := AssetFS()
+ extSuffix := ".tmpl"
+ tmpls := scopedtmpl.NewScopedTemplate()
+ tmpls.Funcs(NewFuncMap())
+ files, err := ListWebTemplateAssetNames(assets)
+ if err != nil {
+ return nil
+ }
+ for _, file := range files {
+ if !strings.HasSuffix(file, extSuffix) {
+ continue
+ }
+ name := strings.TrimSuffix(file, extSuffix)
+ tmpl := tmpls.New(filepath.ToSlash(name))
+ buf, err := assets.ReadFile(file)
+ if err != nil {
+ return err
+ }
+ if _, err = tmpl.Parse(string(buf)); err != nil {
+ return err
+ }
+ }
+ tmpls.Freeze()
+ h.templates.Store(tmpls)
+ return nil
+}
+
+// HTMLRenderer init once and returns the globally shared html renderer
+func HTMLRenderer() *HTMLRender {
+ htmlRenderOnce.Do(initHTMLRenderer)
+ return htmlRender
+}
+
+func ReloadHTMLTemplates() error {
+ log.Trace("Reloading HTML templates")
+ if err := htmlRender.CompileTemplates(); err != nil {
+ log.Error("Template error: %v\n%s", err, log.Stack(2))
+ return err
+ }
+ return nil
+}
+
+func initHTMLRenderer() {
+ rendererType := "static"
+ if !setting.IsProd {
+ rendererType = "auto-reloading"
+ }
+ log.Debug("Creating %s HTML Renderer", rendererType)
+
+ htmlRender = &HTMLRender{}
+ if err := htmlRender.CompileTemplates(); err != nil {
+ p := &templateErrorPrettier{assets: AssetFS()}
+ wrapTmplErrMsg(p.handleFuncNotDefinedError(err))
+ wrapTmplErrMsg(p.handleUnexpectedOperandError(err))
+ wrapTmplErrMsg(p.handleExpectedEndError(err))
+ wrapTmplErrMsg(p.handleGenericTemplateError(err))
+ wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err))
+ }
+
+ if !setting.IsProd {
+ go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
+ _ = ReloadHTMLTemplates()
+ })
+ }
+}
+
+func wrapTmplErrMsg(msg string) {
+ if msg == "" {
+ return
+ }
+ if setting.IsProd {
+ // in prod mode, Forgejo must have correct templates to run
+ log.Fatal("Forgejo can't run with template errors: %s", msg)
+ }
+ // in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded
+ log.Error("There are template errors but Forgejo continues to run in dev mode: %s", msg)
+}
+
+type templateErrorPrettier struct {
+ assets *assetfs.LayeredFS
+}
+
+var reGenericTemplateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
+
+func (p *templateErrorPrettier) handleGenericTemplateError(err error) string {
+ groups := reGenericTemplateError.FindStringSubmatch(err.Error())
+ if len(groups) != 4 {
+ return ""
+ }
+ tmplName, lineStr, message := groups[1], groups[2], groups[3]
+ return p.makeDetailedError(message, tmplName, lineStr, -1, "")
+}
+
+var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`)
+
+func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string {
+ groups := reFuncNotDefinedError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4]
+ funcName, _ = strconv.Unquote(`"` + funcName + `"`)
+ return p.makeDetailedError(message, tmplName, lineStr, -1, funcName)
+}
+
+var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`)
+
+func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string {
+ groups := reUnexpectedOperandError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
+ unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
+ return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
+}
+
+var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`)
+
+func (p *templateErrorPrettier) handleExpectedEndError(err error) string {
+ groups := reExpectedEndError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
+ return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
+}
+
+var (
+ reTemplateExecutingError = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): (executing .*)`)
+ reTemplateExecutingErrorMsg = regexp.MustCompile(`^executing "(.*)" at <(.*)>: `)
+)
+
+func (p *templateErrorPrettier) handleTemplateRenderingError(err error) string {
+ if groups := reTemplateExecutingError.FindStringSubmatch(err.Error()); len(groups) > 0 {
+ tmplName, lineStr, posStr, msgPart := groups[1], groups[2], groups[3], groups[4]
+ target := ""
+ if groups = reTemplateExecutingErrorMsg.FindStringSubmatch(msgPart); len(groups) > 0 {
+ target = groups[2]
+ }
+ return p.makeDetailedError(msgPart, tmplName, lineStr, posStr, target)
+ } else if execErr, ok := err.(texttemplate.ExecError); ok {
+ layerName := p.assets.GetFileLayerName(execErr.Name + ".tmpl")
+ return fmt.Sprintf("asset from: %s, %s", layerName, err.Error())
+ }
+ return err.Error()
+}
+
+func HandleTemplateRenderingError(err error) string {
+ p := &templateErrorPrettier{assets: AssetFS()}
+ return p.handleTemplateRenderingError(err)
+}
+
+const dashSeparator = "----------------------------------------------------------------------"
+
+func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName string, lineNum, posNum any, target string) string {
+ code, layer, err := p.assets.ReadLayeredFile(tmplName + ".tmpl")
+ if err != nil {
+ return fmt.Sprintf("template error: %s, and unable to find template file %q", errMsg, tmplName)
+ }
+ line, err := util.ToInt64(lineNum)
+ if err != nil {
+ return fmt.Sprintf("template error: %s, unable to parse template %q line number %q", errMsg, tmplName, lineNum)
+ }
+ pos, err := util.ToInt64(posNum)
+ if err != nil {
+ return fmt.Sprintf("template error: %s, unable to parse template %q pos number %q", errMsg, tmplName, posNum)
+ }
+ detail := extractErrorLine(code, int(line), int(pos), target)
+
+ var msg string
+ if pos >= 0 {
+ msg = fmt.Sprintf("template error: %s:%s:%d:%d : %s", layer, tmplName, line, pos, errMsg)
+ } else {
+ msg = fmt.Sprintf("template error: %s:%s:%d : %s", layer, tmplName, line, errMsg)
+ }
+ return msg + "\n" + dashSeparator + "\n" + detail + "\n" + dashSeparator
+}
+
+func extractErrorLine(code []byte, lineNum, posNum int, target string) string {
+ b := bufio.NewReader(bytes.NewReader(code))
+ var line []byte
+ var err error
+ for i := 0; i < lineNum; i++ {
+ if line, err = b.ReadBytes('\n'); err != nil {
+ if i == lineNum-1 && errors.Is(err, io.EOF) {
+ err = nil
+ }
+ break
+ }
+ }
+ if err != nil {
+ return fmt.Sprintf("unable to find target line %d", lineNum)
+ }
+
+ line = bytes.TrimRight(line, "\r\n")
+ var indicatorLine []byte
+ targetBytes := []byte(target)
+ targetLen := len(targetBytes)
+ for i := 0; i < len(line); {
+ if posNum == -1 && target != "" && bytes.HasPrefix(line[i:], targetBytes) {
+ for j := 0; j < targetLen && i < len(line); j++ {
+ indicatorLine = append(indicatorLine, '^')
+ i++
+ }
+ } else if i == posNum {
+ indicatorLine = append(indicatorLine, '^')
+ i++
+ } else {
+ if line[i] == '\t' {
+ indicatorLine = append(indicatorLine, '\t')
+ } else {
+ indicatorLine = append(indicatorLine, ' ')
+ }
+ i++
+ }
+ }
+ // if the indicatorLine only contains spaces, trim it together
+ return strings.TrimRight(string(line)+"\n"+string(indicatorLine), " \t\r\n")
+}
diff --git a/modules/templates/htmlrenderer_test.go b/modules/templates/htmlrenderer_test.go
new file mode 100644
index 0000000..a1d3783
--- /dev/null
+++ b/modules/templates/htmlrenderer_test.go
@@ -0,0 +1,107 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "errors"
+ "html/template"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/assetfs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExtractErrorLine(t *testing.T) {
+ cases := []struct {
+ code string
+ line int
+ pos int
+ target string
+ expect string
+ }{
+ {"hello world\nfoo bar foo bar\ntest", 2, -1, "bar", `
+foo bar foo bar
+ ^^^ ^^^
+`},
+
+ {"hello world\nfoo bar foo bar\ntest", 2, 4, "bar", `
+foo bar foo bar
+ ^
+`},
+
+ {
+ "hello world\nfoo bar foo bar\ntest", 2, 4, "",
+ `
+foo bar foo bar
+ ^
+`,
+ },
+
+ {
+ "hello world\nfoo bar foo bar\ntest", 5, 0, "",
+ `unable to find target line 5`,
+ },
+ }
+
+ for _, c := range cases {
+ actual := extractErrorLine([]byte(c.code), c.line, c.pos, c.target)
+ assert.Equal(t, strings.TrimSpace(c.expect), strings.TrimSpace(actual))
+ }
+}
+
+func TestHandleError(t *testing.T) {
+ dir := t.TempDir()
+
+ p := &templateErrorPrettier{assets: assetfs.Layered(assetfs.Local("tmp", dir))}
+
+ test := func(s string, h func(error) string, expect string) {
+ err := os.WriteFile(dir+"/test.tmpl", []byte(s), 0o644)
+ require.NoError(t, err)
+ tmpl := template.New("test")
+ _, err = tmpl.Parse(s)
+ require.Error(t, err)
+ msg := h(err)
+ assert.EqualValues(t, strings.TrimSpace(expect), strings.TrimSpace(msg))
+ }
+
+ test("{{", p.handleGenericTemplateError, `
+template error: tmp:test:1 : unclosed action
+----------------------------------------------------------------------
+{{
+----------------------------------------------------------------------
+`)
+
+ test("{{Func}}", p.handleFuncNotDefinedError, `
+template error: tmp:test:1 : function "Func" not defined
+----------------------------------------------------------------------
+{{Func}}
+ ^^^^
+----------------------------------------------------------------------
+`)
+
+ test("{{'x'3}}", p.handleUnexpectedOperandError, `
+template error: tmp:test:1 : unexpected "3" in operand
+----------------------------------------------------------------------
+{{'x'3}}
+ ^
+----------------------------------------------------------------------
+`)
+
+ // no idea about how to trigger such strange error, so mock an error to test it
+ err := os.WriteFile(dir+"/test.tmpl", []byte("god knows XXX"), 0o644)
+ require.NoError(t, err)
+ expectedMsg := `
+template error: tmp:test:1 : expected end; found XXX
+----------------------------------------------------------------------
+god knows XXX
+ ^^^
+----------------------------------------------------------------------
+`
+ actualMsg := p.handleExpectedEndError(errors.New("template: test:1: expected end; found XXX"))
+ assert.EqualValues(t, strings.TrimSpace(expectedMsg), strings.TrimSpace(actualMsg))
+}
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
new file mode 100644
index 0000000..ee79755
--- /dev/null
+++ b/modules/templates/mailer.go
@@ -0,0 +1,110 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "regexp"
+ "strings"
+ texttmpl "text/template"
+
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
+
+// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
+func mailSubjectTextFuncMap() texttmpl.FuncMap {
+ return texttmpl.FuncMap{
+ "dict": dict,
+ "Eval": Eval,
+
+ "EllipsisString": base.EllipsisString,
+ "AppName": func() string {
+ return setting.AppName
+ },
+ "AppSlogan": func() string {
+ return setting.AppSlogan
+ },
+ "AppDisplayName": func() string {
+ return setting.AppDisplayName
+ },
+ "AppDomain": func() string { // documented in mail-templates.md
+ return setting.Domain
+ },
+ }
+}
+
+func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error {
+ // Split template into subject and body
+ var subjectContent []byte
+ bodyContent := content
+ loc := mailSubjectSplit.FindIndex(content)
+ if loc != nil {
+ subjectContent = content[0:loc[0]]
+ bodyContent = content[loc[1]:]
+ }
+ if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil {
+ return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err)
+ }
+ if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil {
+ return fmt.Errorf("failed to parse template [%s/body]: %w", name, err)
+ }
+ return nil
+}
+
+// Mailer provides the templates required for sending notification mails.
+func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
+ subjectTemplates := texttmpl.New("")
+ bodyTemplates := template.New("")
+
+ subjectTemplates.Funcs(mailSubjectTextFuncMap())
+ bodyTemplates.Funcs(NewFuncMap())
+
+ assetFS := AssetFS()
+ refreshTemplates := func(firstRun bool) {
+ if !firstRun {
+ log.Trace("Reloading mail templates")
+ }
+ assetPaths, err := ListMailTemplateAssetNames(assetFS)
+ if err != nil {
+ log.Error("Failed to list mail templates: %v", err)
+ return
+ }
+
+ for _, assetPath := range assetPaths {
+ content, layerName, err := assetFS.ReadLayeredFile(assetPath)
+ if err != nil {
+ log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err)
+ continue
+ }
+ tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
+ if firstRun {
+ log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
+ }
+ if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
+ if firstRun {
+ log.Fatal("Failed to parse mail template, err: %v", err)
+ }
+ log.Error("Failed to parse mail template, err: %v", err)
+ }
+ }
+ }
+
+ refreshTemplates(true)
+
+ if !setting.IsProd {
+ // Now subjectTemplates and bodyTemplates are both synchronized
+ // thus it is safe to call refresh from a different goroutine
+ go assetFS.WatchLocalChanges(ctx, func() {
+ refreshTemplates(false)
+ })
+ }
+
+ return subjectTemplates, bodyTemplates
+}
diff --git a/modules/templates/main_test.go b/modules/templates/main_test.go
new file mode 100644
index 0000000..bbdf5d2
--- /dev/null
+++ b/modules/templates/main_test.go
@@ -0,0 +1,24 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates_test
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/markup"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/issues"
+)
+
+func TestMain(m *testing.M) {
+ markup.Init(&markup.ProcessorHelper{
+ IsUsernameMentionable: func(ctx context.Context, username string) bool {
+ return username == "mention-user"
+ },
+ })
+ unittest.MainTest(m)
+}
diff --git a/modules/templates/scopedtmpl/scopedtmpl.go b/modules/templates/scopedtmpl/scopedtmpl.go
new file mode 100644
index 0000000..2722ba9
--- /dev/null
+++ b/modules/templates/scopedtmpl/scopedtmpl.go
@@ -0,0 +1,239 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package scopedtmpl
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "reflect"
+ "sync"
+ texttemplate "text/template"
+ "text/template/parse"
+ "unsafe"
+)
+
+type TemplateExecutor interface {
+ Execute(wr io.Writer, data any) error
+}
+
+type ScopedTemplate struct {
+ all *template.Template
+ parseFuncs template.FuncMap // this func map is only used for parsing templates
+ frozen bool
+
+ scopedMu sync.RWMutex
+ scopedTemplateSets map[string]*scopedTemplateSet
+}
+
+func NewScopedTemplate() *ScopedTemplate {
+ return &ScopedTemplate{
+ all: template.New(""),
+ parseFuncs: template.FuncMap{},
+ scopedTemplateSets: map[string]*scopedTemplateSet{},
+ }
+}
+
+func (t *ScopedTemplate) Funcs(funcMap template.FuncMap) {
+ if t.frozen {
+ panic("cannot add new functions to frozen template set")
+ }
+ t.all.Funcs(funcMap)
+ for k, v := range funcMap {
+ t.parseFuncs[k] = v
+ }
+}
+
+func (t *ScopedTemplate) New(name string) *template.Template {
+ if t.frozen {
+ panic("cannot add new template to frozen template set")
+ }
+ return t.all.New(name)
+}
+
+func (t *ScopedTemplate) Freeze() {
+ t.frozen = true
+ // reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping
+ m := template.FuncMap{}
+ for k := range t.parseFuncs {
+ m[k] = func(v ...any) any { return nil }
+ }
+ t.all.Funcs(m)
+}
+
+func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
+ t.scopedMu.RLock()
+ scopedTmplSet, ok := t.scopedTemplateSets[name]
+ t.scopedMu.RUnlock()
+
+ if !ok {
+ var err error
+ t.scopedMu.Lock()
+ if scopedTmplSet, ok = t.scopedTemplateSets[name]; !ok {
+ if scopedTmplSet, err = newScopedTemplateSet(t.all, name); err == nil {
+ t.scopedTemplateSets[name] = scopedTmplSet
+ }
+ }
+ t.scopedMu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if scopedTmplSet == nil {
+ return nil, fmt.Errorf("template %s not found", name)
+ }
+ return scopedTmplSet.newExecutor(funcMap), nil
+}
+
+type scopedTemplateSet struct {
+ name string
+ htmlTemplates map[string]*template.Template
+ textTemplates map[string]*texttemplate.Template
+ execFuncs map[string]reflect.Value
+}
+
+func escapeTemplate(t *template.Template) error {
+ // force the Golang HTML template to complete the escaping work
+ err := t.Execute(io.Discard, nil)
+ if _, ok := err.(*template.Error); ok {
+ return err
+ }
+ return nil
+}
+
+//nolint:unused
+type htmlTemplate struct {
+ escapeErr error
+ text *texttemplate.Template
+}
+
+//nolint:unused
+type textTemplateCommon struct {
+ tmpl map[string]*template.Template // Map from name to defined templates.
+ muTmpl sync.RWMutex // protects tmpl
+ option struct {
+ missingKey int
+ }
+ muFuncs sync.RWMutex // protects parseFuncs and execFuncs
+ parseFuncs texttemplate.FuncMap
+ execFuncs map[string]reflect.Value
+}
+
+//nolint:unused
+type textTemplate struct {
+ name string
+ *parse.Tree
+ *textTemplateCommon
+ leftDelim string
+ rightDelim string
+}
+
+func ptr[T, P any](ptr *P) *T {
+ // https://pkg.go.dev/unsafe#Pointer
+ // (1) Conversion of a *T1 to Pointer to *T2.
+ // Provided that T2 is no larger than T1 and that the two share an equivalent memory layout,
+ // this conversion allows reinterpreting data of one type as data of another type.
+ return (*T)(unsafe.Pointer(ptr))
+}
+
+func newScopedTemplateSet(all *template.Template, name string) (*scopedTemplateSet, error) {
+ targetTmpl := all.Lookup(name)
+ if targetTmpl == nil {
+ return nil, fmt.Errorf("template %q not found", name)
+ }
+ if err := escapeTemplate(targetTmpl); err != nil {
+ return nil, fmt.Errorf("template %q has an error when escaping: %w", name, err)
+ }
+
+ ts := &scopedTemplateSet{
+ name: name,
+ htmlTemplates: map[string]*template.Template{},
+ textTemplates: map[string]*texttemplate.Template{},
+ }
+
+ htmlTmpl := ptr[htmlTemplate](all)
+ textTmpl := htmlTmpl.text
+ textTmplPtr := ptr[textTemplate](textTmpl)
+
+ textTmplPtr.muFuncs.Lock()
+ ts.execFuncs = map[string]reflect.Value{}
+ for k, v := range textTmplPtr.execFuncs {
+ ts.execFuncs[k] = v
+ }
+ textTmplPtr.muFuncs.Unlock()
+
+ var collectTemplates func(nodes []parse.Node)
+ var collectErr error // only need to collect the one error
+ collectTemplates = func(nodes []parse.Node) {
+ for _, node := range nodes {
+ if node.Type() == parse.NodeTemplate {
+ nodeTemplate := node.(*parse.TemplateNode)
+ subName := nodeTemplate.Name
+ if ts.htmlTemplates[subName] == nil {
+ subTmpl := all.Lookup(subName)
+ if subTmpl == nil {
+ // HTML template will add some internal templates like "$delimDoubleQuote" into the text template
+ ts.textTemplates[subName] = textTmpl.Lookup(subName)
+ } else if subTmpl.Tree == nil || subTmpl.Tree.Root == nil {
+ collectErr = fmt.Errorf("template %q has no tree, it's usually caused by broken templates", subName)
+ } else {
+ ts.htmlTemplates[subName] = subTmpl
+ if err := escapeTemplate(subTmpl); err != nil {
+ collectErr = fmt.Errorf("template %q has an error when escaping: %w", subName, err)
+ return
+ }
+ collectTemplates(subTmpl.Tree.Root.Nodes)
+ }
+ }
+ } else if node.Type() == parse.NodeList {
+ nodeList := node.(*parse.ListNode)
+ collectTemplates(nodeList.Nodes)
+ } else if node.Type() == parse.NodeIf {
+ nodeIf := node.(*parse.IfNode)
+ collectTemplates(nodeIf.BranchNode.List.Nodes)
+ if nodeIf.BranchNode.ElseList != nil {
+ collectTemplates(nodeIf.BranchNode.ElseList.Nodes)
+ }
+ } else if node.Type() == parse.NodeRange {
+ nodeRange := node.(*parse.RangeNode)
+ collectTemplates(nodeRange.BranchNode.List.Nodes)
+ if nodeRange.BranchNode.ElseList != nil {
+ collectTemplates(nodeRange.BranchNode.ElseList.Nodes)
+ }
+ } else if node.Type() == parse.NodeWith {
+ nodeWith := node.(*parse.WithNode)
+ collectTemplates(nodeWith.BranchNode.List.Nodes)
+ if nodeWith.BranchNode.ElseList != nil {
+ collectTemplates(nodeWith.BranchNode.ElseList.Nodes)
+ }
+ }
+ }
+ }
+ ts.htmlTemplates[name] = targetTmpl
+ collectTemplates(targetTmpl.Tree.Root.Nodes)
+ return ts, collectErr
+}
+
+func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor {
+ tmpl := texttemplate.New("")
+ tmplPtr := ptr[textTemplate](tmpl)
+ tmplPtr.execFuncs = map[string]reflect.Value{}
+ for k, v := range ts.execFuncs {
+ tmplPtr.execFuncs[k] = v
+ }
+ if funcMap != nil {
+ tmpl.Funcs(funcMap)
+ }
+ // after escapeTemplate, the html templates are also escaped text templates, so it could be added to the text template directly
+ for _, t := range ts.htmlTemplates {
+ _, _ = tmpl.AddParseTree(t.Name(), t.Tree)
+ }
+ for _, t := range ts.textTemplates {
+ _, _ = tmpl.AddParseTree(t.Name(), t.Tree)
+ }
+
+ // now the text template has all necessary escaped templates, so we can safely execute, just like what the html template does
+ return tmpl.Lookup(ts.name)
+}
diff --git a/modules/templates/scopedtmpl/scopedtmpl_test.go b/modules/templates/scopedtmpl/scopedtmpl_test.go
new file mode 100644
index 0000000..9bbd0c7
--- /dev/null
+++ b/modules/templates/scopedtmpl/scopedtmpl_test.go
@@ -0,0 +1,99 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package scopedtmpl
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestScopedTemplateSetFuncMap(t *testing.T) {
+ all := template.New("")
+
+ all.Funcs(template.FuncMap{"CtxFunc": func(s string) string {
+ return "default"
+ }})
+
+ _, err := all.New("base").Parse(`{{CtxFunc "base"}}`)
+ require.NoError(t, err)
+
+ _, err = all.New("test").Parse(strings.TrimSpace(`
+{{template "base"}}
+{{CtxFunc "test"}}
+{{template "base"}}
+{{CtxFunc "test"}}
+`))
+ require.NoError(t, err)
+
+ ts, err := newScopedTemplateSet(all, "test")
+ require.NoError(t, err)
+
+ // try to use different CtxFunc to render concurrently
+
+ funcMap1 := template.FuncMap{
+ "CtxFunc": func(s string) string {
+ time.Sleep(100 * time.Millisecond)
+ return s + "1"
+ },
+ }
+
+ funcMap2 := template.FuncMap{
+ "CtxFunc": func(s string) string {
+ time.Sleep(100 * time.Millisecond)
+ return s + "2"
+ },
+ }
+
+ out1 := bytes.Buffer{}
+ out2 := bytes.Buffer{}
+ wg := sync.WaitGroup{}
+ wg.Add(2)
+ go func() {
+ err := ts.newExecutor(funcMap1).Execute(&out1, nil)
+ require.NoError(t, err)
+ wg.Done()
+ }()
+ go func() {
+ err := ts.newExecutor(funcMap2).Execute(&out2, nil)
+ require.NoError(t, err)
+ wg.Done()
+ }()
+ wg.Wait()
+ assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String())
+ assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String())
+}
+
+func TestScopedTemplateSetEscape(t *testing.T) {
+ all := template.New("")
+ _, err := all.New("base").Parse(`<a href="?q={{.param}}">{{.text}}</a>`)
+ require.NoError(t, err)
+
+ _, err = all.New("test").Parse(`{{template "base" .}}<form action="?q={{.param}}">{{.text}}</form>`)
+ require.NoError(t, err)
+
+ ts, err := newScopedTemplateSet(all, "test")
+ require.NoError(t, err)
+
+ out := bytes.Buffer{}
+ err = ts.newExecutor(nil).Execute(&out, map[string]string{"param": "/", "text": "<"})
+ require.NoError(t, err)
+
+ assert.Equal(t, `<a href="?q=%2f">&lt;</a><form action="?q=%2f">&lt;</form>`, out.String())
+}
+
+func TestScopedTemplateSetUnsafe(t *testing.T) {
+ all := template.New("")
+ _, err := all.New("test").Parse(`<a href="{{if true}}?{{end}}a={{.param}}"></a>`)
+ require.NoError(t, err)
+
+ _, err = newScopedTemplateSet(all, "test")
+ require.ErrorContains(t, err, "appears in an ambiguous context within a URL")
+}
diff --git a/modules/templates/static.go b/modules/templates/static.go
new file mode 100644
index 0000000..b5a7e56
--- /dev/null
+++ b/modules/templates/static.go
@@ -0,0 +1,22 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package templates
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// GlobalModTime provide a global mod time for embedded asset files
+func GlobalModTime(filename string) time.Time {
+ return timeutil.GetExecutableModTime()
+}
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Bindata("builtin(bindata)", Assets)
+}
diff --git a/modules/templates/templates_bindata.go b/modules/templates/templates_bindata.go
new file mode 100644
index 0000000..6f1d3cf
--- /dev/null
+++ b/modules/templates/templates_bindata.go
@@ -0,0 +1,8 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package templates
+
+//go:generate go run ../../build/generate-bindata.go ../../templates templates bindata.go true
diff --git a/modules/templates/util_avatar.go b/modules/templates/util_avatar.go
new file mode 100644
index 0000000..afc1091
--- /dev/null
+++ b/modules/templates/util_avatar.go
@@ -0,0 +1,81 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "html/template"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/avatars"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ gitea_html "code.gitea.io/gitea/modules/html"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type AvatarUtils struct {
+ ctx context.Context
+}
+
+func NewAvatarUtils(ctx context.Context) *AvatarUtils {
+ return &AvatarUtils{ctx: ctx}
+}
+
+// AvatarHTML creates the HTML for an avatar
+func AvatarHTML(src string, size int, class, name string) template.HTML {
+ sizeStr := fmt.Sprintf(`%d`, size)
+
+ if name == "" {
+ name = "avatar"
+ }
+
+ return template.HTML(`<img loading="lazy" class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`)
+}
+
+// Avatar renders user avatars. args: user, size (int), class (string)
+func (au *AvatarUtils) Avatar(item any, others ...any) template.HTML {
+ size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
+
+ switch t := item.(type) {
+ case *user_model.User:
+ src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.DisplayName())
+ }
+ case *repo_model.Collaborator:
+ src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.DisplayName())
+ }
+ case *organization.Organization:
+ src := t.AsUser().AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.AsUser().DisplayName())
+ }
+ }
+
+ return AvatarHTML(avatars.DefaultAvatarLink(), size, class, "")
+}
+
+// AvatarByAction renders user avatars from action. args: action, size (int), class (string)
+func (au *AvatarUtils) AvatarByAction(action *activities_model.Action, others ...any) template.HTML {
+ action.LoadActUser(au.ctx)
+ return au.Avatar(action.ActUser, others...)
+}
+
+// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string)
+func (au *AvatarUtils) AvatarByEmail(email, name string, others ...any) template.HTML {
+ size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
+ src := avatars.GenerateEmailAvatarFastLink(au.ctx, email, size*setting.Avatar.RenderedSizeFactor)
+
+ if src != "" {
+ return AvatarHTML(src, size, class, name)
+ }
+
+ return ""
+}
diff --git a/modules/templates/util_dict.go b/modules/templates/util_dict.go
new file mode 100644
index 0000000..8d6376b
--- /dev/null
+++ b/modules/templates/util_dict.go
@@ -0,0 +1,121 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "reflect"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func dictMerge(base map[string]any, arg any) bool {
+ if arg == nil {
+ return true
+ }
+ rv := reflect.ValueOf(arg)
+ if rv.Kind() == reflect.Map {
+ for _, k := range rv.MapKeys() {
+ base[k.String()] = rv.MapIndex(k).Interface()
+ }
+ return true
+ }
+ return false
+}
+
+// dict is a helper function for creating a map[string]any from a list of key-value pairs.
+// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
+// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
+func dict(args ...any) (map[string]any, error) {
+ if len(args)%2 != 0 {
+ return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs")
+ }
+ m := make(map[string]any, len(args)/2)
+ for i := 0; i < len(args); i += 2 {
+ key, ok := args[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
+ }
+ if key == "." {
+ if ok = dictMerge(m, args[i+1]); !ok {
+ return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
+ }
+ } else {
+ m[key] = args[i+1]
+ }
+ }
+ return m, nil
+}
+
+func dumpVarMarshalable(v any, dumped container.Set[uintptr]) (ret any, ok bool) {
+ if v == nil {
+ return nil, true
+ }
+ e := reflect.ValueOf(v)
+ for e.Kind() == reflect.Pointer {
+ e = e.Elem()
+ }
+ if e.CanAddr() {
+ addr := e.UnsafeAddr()
+ if !dumped.Add(addr) {
+ return "[dumped]", false
+ }
+ defer dumped.Remove(addr)
+ }
+ switch e.Kind() {
+ case reflect.Bool, reflect.String,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+ reflect.Float32, reflect.Float64:
+ return e.Interface(), true
+ case reflect.Struct:
+ m := map[string]any{}
+ for i := 0; i < e.NumField(); i++ {
+ k := e.Type().Field(i).Name
+ if !e.Type().Field(i).IsExported() {
+ continue
+ }
+ v := e.Field(i).Interface()
+ m[k], _ = dumpVarMarshalable(v, dumped)
+ }
+ return m, true
+ case reflect.Map:
+ m := map[string]any{}
+ for _, k := range e.MapKeys() {
+ m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped)
+ }
+ return m, true
+ case reflect.Array, reflect.Slice:
+ var m []any
+ for i := 0; i < e.Len(); i++ {
+ v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped)
+ m = append(m, v)
+ }
+ return m, true
+ default:
+ return "[" + reflect.TypeOf(v).String() + "]", false
+ }
+}
+
+// dumpVar helps to dump a variable in a template, to help debugging and development.
+func dumpVar(v any) template.HTML {
+ if setting.IsProd {
+ return "<pre>dumpVar: only available in dev mode</pre>"
+ }
+ m, ok := dumpVarMarshalable(v, make(container.Set[uintptr]))
+ var dumpStr string
+ jsonBytes, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err)
+ } else if ok {
+ dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes))
+ } else {
+ dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes))
+ }
+ return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>")
+}
diff --git a/modules/templates/util_json.go b/modules/templates/util_json.go
new file mode 100644
index 0000000..71a4e23
--- /dev/null
+++ b/modules/templates/util_json.go
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "bytes"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+type JsonUtils struct{} //nolint:revive
+
+var jsonUtils = JsonUtils{}
+
+func NewJsonUtils() *JsonUtils { //nolint:revive
+ return &jsonUtils
+}
+
+func (su *JsonUtils) EncodeToString(v any) string {
+ out, err := json.Marshal(v)
+ if err != nil {
+ return ""
+ }
+ return string(out)
+}
+
+func (su *JsonUtils) PrettyIndent(s string) string {
+ var out bytes.Buffer
+ err := json.Indent(&out, []byte(s), "", " ")
+ if err != nil {
+ return ""
+ }
+ return out.String()
+}
diff --git a/modules/templates/util_misc.go b/modules/templates/util_misc.go
new file mode 100644
index 0000000..7743854
--- /dev/null
+++ b/modules/templates/util_misc.go
@@ -0,0 +1,193 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "html/template"
+ "mime"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ giturl "code.gitea.io/gitea/modules/git/url"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/svg"
+
+ "github.com/editorconfig/editorconfig-core-go/v2"
+)
+
+func SortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML {
+ // if needed
+ if len(normSort) == 0 || len(urlSort) == 0 {
+ return ""
+ }
+
+ if len(urlSort) == 0 && isDefault {
+ // if sort is sorted as default add arrow tho this table header
+ if isDefault {
+ return svg.RenderHTML("octicon-triangle-down", 16)
+ }
+ } else {
+ // if sort arg is in url test if it correlates with column header sort arguments
+ // the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
+ if urlSort == normSort {
+ // the table is sorted with this header normal
+ return svg.RenderHTML("octicon-triangle-up", 16)
+ } else if urlSort == revSort {
+ // the table is sorted with this header reverse
+ return svg.RenderHTML("octicon-triangle-down", 16)
+ }
+ }
+ // the table is NOT sorted with this header
+ return ""
+}
+
+// IsMultilineCommitMessage checks to see if a commit message contains multiple lines.
+func IsMultilineCommitMessage(msg string) bool {
+ return strings.Count(strings.TrimSpace(msg), "\n") >= 1
+}
+
+// Actioner describes an action
+type Actioner interface {
+ GetOpType() activities_model.ActionType
+ GetActUserName(ctx context.Context) string
+ GetRepoUserName(ctx context.Context) string
+ GetRepoName(ctx context.Context) string
+ GetRepoPath(ctx context.Context) string
+ GetRepoLink(ctx context.Context) string
+ GetBranch() string
+ GetContent() string
+ GetCreate() time.Time
+ GetIssueInfos() []string
+}
+
+// ActionIcon accepts an action operation type and returns an icon class name.
+func ActionIcon(opType activities_model.ActionType) string {
+ switch opType {
+ case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo:
+ return "repo"
+ case activities_model.ActionCommitRepo:
+ return "git-commit"
+ case activities_model.ActionDeleteBranch:
+ return "git-branch"
+ case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
+ return "git-merge"
+ case activities_model.ActionCreatePullRequest:
+ return "git-pull-request"
+ case activities_model.ActionClosePullRequest:
+ return "git-pull-request-closed"
+ case activities_model.ActionCreateIssue:
+ return "issue-opened"
+ case activities_model.ActionCloseIssue:
+ return "issue-closed"
+ case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
+ return "issue-reopened"
+ case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
+ return "comment-discussion"
+ case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete:
+ return "mirror"
+ case activities_model.ActionApprovePullRequest:
+ return "check"
+ case activities_model.ActionRejectPullRequest:
+ return "file-diff"
+ case activities_model.ActionPublishRelease, activities_model.ActionPushTag, activities_model.ActionDeleteTag:
+ return "tag"
+ case activities_model.ActionPullReviewDismissed:
+ return "x"
+ default:
+ return "question"
+ }
+}
+
+// ActionContent2Commits converts action content to push commits
+func ActionContent2Commits(act Actioner) *repository.PushCommits {
+ push := repository.NewPushCommits()
+
+ if act == nil || act.GetContent() == "" {
+ return push
+ }
+
+ if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil {
+ log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err)
+ }
+
+ if push.Len == 0 {
+ push.Len = len(push.Commits)
+ }
+
+ return push
+}
+
+// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from
+func MigrationIcon(hostname string) string {
+ switch hostname {
+ case "github.com":
+ return "octicon-mark-github"
+ default:
+ return "gitea-git"
+ }
+}
+
+type remoteAddress struct {
+ Address string
+ Username string
+ Password string
+}
+
+func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string) remoteAddress {
+ ret := remoteAddress{}
+ remoteURL, err := git.GetRemoteAddress(ctx, m.RepoPath(), remoteName)
+ if err != nil {
+ log.Error("GetRemoteURL %v", err)
+ return ret
+ }
+
+ u, err := giturl.Parse(remoteURL)
+ if err != nil {
+ log.Error("giturl.Parse %v", err)
+ return ret
+ }
+
+ if u.Scheme != "ssh" && u.Scheme != "file" {
+ if u.User != nil {
+ ret.Username = u.User.Username()
+ ret.Password, _ = u.User.Password()
+ }
+ }
+
+ // The URL stored in the git repo could contain authentication,
+ // erase it, or it will be shown in the UI.
+ u.User = nil
+ ret.Address = u.String()
+ // Why not use m.OriginalURL to set ret.Address?
+ // It should be OK to use it, since m.OriginalURL should be the same as the authentication-erased URL from the Git repository.
+ // However, the old code has already stored authentication in m.OriginalURL when updating mirror settings.
+ // That means we need to use "giturl.Parse" for m.OriginalURL again to ensure authentication is erased.
+ // Instead of doing this, why not directly use the authentication-erased URL from the Git repository?
+ // It should be the same as long as there are no bugs.
+
+ return ret
+}
+
+func FilenameIsImage(filename string) bool {
+ mimeType := mime.TypeByExtension(filepath.Ext(filename))
+ return strings.HasPrefix(mimeType, "image/")
+}
+
+func TabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
+ if ec != nil {
+ def, err := ec.GetDefinitionForFilename(filename)
+ if err == nil && def.TabWidth >= 1 && def.TabWidth <= 16 {
+ return "tab-size-" + strconv.Itoa(def.TabWidth)
+ }
+ }
+ return "tab-size-4"
+}
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
new file mode 100644
index 0000000..c53bdd8
--- /dev/null
+++ b/modules/templates/util_render.go
@@ -0,0 +1,264 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "math"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// RenderCommitMessage renders commit message with XSS-safe and special links.
+func RenderCommitMessage(ctx context.Context, msg string, metas map[string]string) template.HTML {
+ cleanMsg := template.HTMLEscapeString(msg)
+ // we can safely assume that it will not return any error, since there
+ // shouldn't be any special HTML.
+ fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, cleanMsg)
+ if err != nil {
+ log.Error("RenderCommitMessage: %v", err)
+ return ""
+ }
+ msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n")
+ if len(msgLines) == 0 {
+ return template.HTML("")
+ }
+ return RenderCodeBlock(template.HTML(msgLines[0]))
+}
+
+// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
+// the provided default url, handling for special links without email to links.
+func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML {
+ msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
+ lineEnd := strings.IndexByte(msgLine, '\n')
+ if lineEnd > 0 {
+ msgLine = msgLine[:lineEnd]
+ }
+ msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
+ if len(msgLine) == 0 {
+ return template.HTML("")
+ }
+
+ // we can safely assume that it will not return any error, since there
+ // shouldn't be any special HTML.
+ renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{
+ Ctx: ctx,
+ DefaultLink: urlDefault,
+ Metas: metas,
+ }, template.HTMLEscapeString(msgLine))
+ if err != nil {
+ log.Error("RenderCommitMessageSubject: %v", err)
+ return template.HTML("")
+ }
+ return RenderCodeBlock(template.HTML(renderedMessage))
+}
+
+// RenderCommitBody extracts the body of a commit message without its title.
+func RenderCommitBody(ctx context.Context, msg string, metas map[string]string) template.HTML {
+ msgLine := strings.TrimSpace(msg)
+ lineEnd := strings.IndexByte(msgLine, '\n')
+ if lineEnd > 0 {
+ msgLine = msgLine[lineEnd+1:]
+ } else {
+ return ""
+ }
+ msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace)
+ if len(msgLine) == 0 {
+ return ""
+ }
+
+ renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, template.HTMLEscapeString(msgLine))
+ if err != nil {
+ log.Error("RenderCommitMessage: %v", err)
+ return ""
+ }
+ return template.HTML(renderedMessage)
+}
+
+// Match text that is between back ticks.
+var codeMatcher = regexp.MustCompile("`([^`]+)`")
+
+// RenderCodeBlock renders "`…`" as highlighted "<code>" block, intended for issue and PR titles
+func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
+ htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags
+ return template.HTML(htmlWithCodeTags)
+}
+
+const (
+ activeLabelOpacity = uint8(255)
+ archivedLabelOpacity = uint8(127)
+)
+
+func GetLabelOpacityByte(isArchived bool) uint8 {
+ if isArchived {
+ return archivedLabelOpacity
+ }
+ return activeLabelOpacity
+}
+
+// RenderIssueTitle renders issue/pull title with defined post processors
+func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML {
+ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderIssueTitle: %v", err)
+ return template.HTML("")
+ }
+ return template.HTML(renderedText)
+}
+
+// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
+func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
+ renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderRefIssueTitle: %v", err)
+ return ""
+ }
+
+ return template.HTML(renderedText)
+}
+
+// RenderLabel renders a label
+// locale is needed due to an import cycle with our context providing the `Tr` function
+func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
+ var (
+ archivedCSSClass string
+ textColor = util.ContrastColor(label.Color)
+ labelScope = label.ExclusiveScope()
+ )
+
+ description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
+
+ if label.IsArchived() {
+ archivedCSSClass = "archived-label"
+ description = locale.TrString("repo.issues.archived_label_description", description)
+ }
+
+ if labelScope == "" {
+ // Regular label
+
+ labelColor := label.Color + hex.EncodeToString([]byte{GetLabelOpacityByte(label.IsArchived())})
+ s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
+ archivedCSSClass, textColor, labelColor, description, RenderEmoji(ctx, label.Name))
+ return template.HTML(s)
+ }
+
+ // Scoped label
+ scopeText := RenderEmoji(ctx, labelScope)
+ itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
+
+ // Make scope and item background colors slightly darker and lighter respectively.
+ // More contrast needed with higher luminance, empirically tweaked.
+ luminance := util.GetRelativeLuminance(label.Color)
+ contrast := 0.01 + luminance*0.03
+ // Ensure we add the same amount of contrast also near 0 and 1.
+ darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
+ lighten := contrast + math.Max(contrast-luminance, 0.0)
+ // Compute factor to keep RGB values proportional.
+ darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
+ lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
+
+ opacity := GetLabelOpacityByte(label.IsArchived())
+ r, g, b := util.HexToRBGColor(label.Color)
+ scopeBytes := []byte{
+ uint8(math.Min(math.Round(r*darkenFactor), 255)),
+ uint8(math.Min(math.Round(g*darkenFactor), 255)),
+ uint8(math.Min(math.Round(b*darkenFactor), 255)),
+ opacity,
+ }
+ itemBytes := []byte{
+ uint8(math.Min(math.Round(r*lightenFactor), 255)),
+ uint8(math.Min(math.Round(g*lightenFactor), 255)),
+ uint8(math.Min(math.Round(b*lightenFactor), 255)),
+ opacity,
+ }
+
+ scopeColor := "#" + hex.EncodeToString(scopeBytes)
+ itemColor := "#" + hex.EncodeToString(itemBytes)
+
+ s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
+ "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
+ "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
+ "</span>",
+ archivedCSSClass, description,
+ textColor, scopeColor, scopeText,
+ textColor, itemColor, itemText)
+ return template.HTML(s)
+}
+
+// RenderEmoji renders html text with emoji post processors
+func RenderEmoji(ctx context.Context, text string) template.HTML {
+ renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx},
+ template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderEmoji: %v", err)
+ return template.HTML("")
+ }
+ return template.HTML(renderedText)
+}
+
+// ReactionToEmoji renders emoji for use in reactions
+func ReactionToEmoji(reaction string) template.HTML {
+ val := emoji.FromCode(reaction)
+ if val != nil {
+ return template.HTML(val.Emoji)
+ }
+ val = emoji.FromAlias(reaction)
+ if val != nil {
+ return template.HTML(val.Emoji)
+ }
+ return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
+}
+
+func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive
+ output, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: map[string]string{"mode": "document"},
+ }, input)
+ if err != nil {
+ log.Error("RenderString: %v", err)
+ }
+ return output
+}
+
+func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, isPull bool) template.HTML {
+ htmlCode := `<span class="labels-list">`
+ for _, label := range labels {
+ // Protect against nil value in labels - shouldn't happen but would cause a panic if so
+ if label == nil {
+ continue
+ }
+
+ issuesOrPull := "issues"
+ if isPull {
+ issuesOrPull = "pulls"
+ }
+ htmlCode += fmt.Sprintf("<a href='%s/%s?labels=%d' rel='nofollow'>%s</a> ",
+ repoLink, issuesOrPull, label.ID, RenderLabel(ctx, locale, label))
+ }
+ htmlCode += "</span>"
+ return template.HTML(htmlCode)
+}
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
new file mode 100644
index 0000000..da74298
--- /dev/null
+++ b/modules/templates/util_render_test.go
@@ -0,0 +1,223 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "html/template"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const testInput = ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+:+1:
+mail@domain.com
+@mention-user test
+#123
+ space
+` + "`code :+1: #123 code`\n"
+
+var testMetas = map[string]string{
+ "user": "user13",
+ "repo": "repo11",
+ "repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/",
+ "mode": "comment",
+}
+
+func TestApostrophesInMentions(t *testing.T) {
+ rendered := RenderMarkdownToHtml(context.Background(), "@mention-user's comment")
+ assert.EqualValues(t, template.HTML("<p><a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a>&#39;s comment</p>\n"), rendered)
+}
+
+func TestNonExistantUserMention(t *testing.T) {
+ rendered := RenderMarkdownToHtml(context.Background(), "@ThisUserDoesNotExist @mention-user")
+ assert.EqualValues(t, template.HTML("<p>@ThisUserDoesNotExist <a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a></p>\n"), rendered)
+}
+
+func TestRenderCommitBody(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ msg string
+ metas map[string]string
+ }
+ tests := []struct {
+ name string
+ args args
+ want template.HTML
+ }{
+ {
+ name: "multiple lines",
+ args: args{
+ ctx: context.Background(),
+ msg: "first line\nsecond line",
+ },
+ want: "second line",
+ },
+ {
+ name: "multiple lines with leading newlines",
+ args: args{
+ ctx: context.Background(),
+ msg: "\n\n\n\nfirst line\nsecond line",
+ },
+ want: "second line",
+ },
+ {
+ name: "multiple lines with trailing newlines",
+ args: args{
+ ctx: context.Background(),
+ msg: "first line\nsecond line\n\n\n",
+ },
+ want: "second line",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, RenderCommitBody(tt.args.ctx, tt.args.msg, tt.args.metas), "RenderCommitBody(%v, %v, %v)", tt.args.ctx, tt.args.msg, tt.args.metas)
+ })
+ }
+
+ expected := `/just/a/path.bin
+<a href="https://example.com/file.bin" class="link">https://example.com/file.bin</a>
+[local link](file.bin)
+[remote link](<a href="https://example.com" class="link">https://example.com</a>)
+[[local link|file.bin]]
+[[remote link|<a href="https://example.com" class="link">https://example.com</a>]]
+![local image](image.jpg)
+![remote image](<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>)
+[[local image|image.jpg]]
+[[remote link|<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>]]
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span>
+<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
+<a href="/mention-user" class="mention">@mention-user</a> test
+<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
+ space
+` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">ðŸ‘</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`"
+ assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
+}
+
+func TestRenderCommitMessage(t *testing.T) {
+ expected := `space <a href="/mention-user" class="mention">@mention-user</a> `
+
+ assert.EqualValues(t, expected, RenderCommitMessage(context.Background(), testInput, testMetas))
+}
+
+func TestRenderCommitMessageLinkSubject(t *testing.T) {
+ expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>`
+
+ assert.EqualValues(t, expected, RenderCommitMessageLinkSubject(context.Background(), testInput, "https://example.com/link", testMetas))
+}
+
+func TestRenderIssueTitle(t *testing.T) {
+ expected := ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span>
+mail@domain.com
+@mention-user test
+<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
+ space
+<code class="inline-code-block">code :+1: #123 code</code>
+`
+ assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
+}
+
+func TestRenderRefIssueTitle(t *testing.T) {
+ expected := ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span>
+mail@domain.com
+@mention-user test
+#123
+ space
+<code class="inline-code-block">code :+1: #123 code</code>
+`
+ assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput))
+}
+
+func TestRenderMarkdownToHtml(t *testing.T) {
+ expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
+/just/a/path.bin
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
+<a href="/file.bin" rel="nofollow">local link</a>
+<a href="https://example.com" rel="nofollow">remote link</a>
+<a href="/src/file.bin" rel="nofollow">local link</a>
+<a href="https://example.com" rel="nofollow">remote link</a>
+<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
+<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">ðŸ‘</span>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
+<a href="/mention-user" rel="nofollow">@mention-user</a> test
+#123
+space
+<code>code :+1: #123 code</code></p>
+`
+ assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput))
+}
+
+func TestRenderLabels(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ tr := &translation.MockLocale{}
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
+
+ assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false),
+ "user2/repo1/issues?labels=1")
+ assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true),
+ "user2/repo1/pulls?labels=1")
+}
diff --git a/modules/templates/util_slice.go b/modules/templates/util_slice.go
new file mode 100644
index 0000000..a3318cc
--- /dev/null
+++ b/modules/templates/util_slice.go
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "reflect"
+)
+
+type SliceUtils struct{}
+
+func NewSliceUtils() *SliceUtils {
+ return &SliceUtils{}
+}
+
+func (su *SliceUtils) Contains(s, v any) bool {
+ if s == nil {
+ return false
+ }
+ sv := reflect.ValueOf(s)
+ if sv.Kind() != reflect.Slice && sv.Kind() != reflect.Array {
+ panic(fmt.Sprintf("invalid type, expected slice or array, but got: %T", s))
+ }
+ for i := 0; i < sv.Len(); i++ {
+ it := sv.Index(i)
+ if !it.CanInterface() {
+ continue
+ }
+ if it.Interface() == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go
new file mode 100644
index 0000000..f23b747
--- /dev/null
+++ b/modules/templates/util_string.go
@@ -0,0 +1,68 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "code.gitea.io/gitea/modules/base"
+)
+
+type StringUtils struct{}
+
+var stringUtils = StringUtils{}
+
+func NewStringUtils() *StringUtils {
+ return &stringUtils
+}
+
+func (su *StringUtils) HasPrefix(s any, prefix string) bool {
+ switch v := s.(type) {
+ case string:
+ return strings.HasPrefix(v, prefix)
+ case template.HTML:
+ return strings.HasPrefix(string(v), prefix)
+ }
+ return false
+}
+
+func (su *StringUtils) ToString(v any) string {
+ switch v := v.(type) {
+ case string:
+ return v
+ case template.HTML:
+ return string(v)
+ case fmt.Stringer:
+ return v.String()
+ default:
+ return fmt.Sprint(v)
+ }
+}
+
+func (su *StringUtils) Contains(s, substr string) bool {
+ return strings.Contains(s, substr)
+}
+
+func (su *StringUtils) Split(s, sep string) []string {
+ return strings.Split(s, sep)
+}
+
+func (su *StringUtils) Join(a []string, sep string) string {
+ return strings.Join(a, sep)
+}
+
+func (su *StringUtils) Cut(s, sep string) []any {
+ before, after, found := strings.Cut(s, sep)
+ return []any{before, after, found}
+}
+
+func (su *StringUtils) EllipsisString(s string, max int) string {
+ return base.EllipsisString(s, max)
+}
+
+func (su *StringUtils) ToUpper(s string) string {
+ return strings.ToUpper(s)
+}
diff --git a/modules/templates/util_string_test.go b/modules/templates/util_string_test.go
new file mode 100644
index 0000000..5844cf2
--- /dev/null
+++ b/modules/templates/util_string_test.go
@@ -0,0 +1,20 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_StringUtils_HasPrefix(t *testing.T) {
+ su := &StringUtils{}
+ assert.True(t, su.HasPrefix("ABC", "A"))
+ assert.False(t, su.HasPrefix("ABC", "B"))
+ assert.True(t, su.HasPrefix(template.HTML("ABC"), "A"))
+ assert.False(t, su.HasPrefix(template.HTML("ABC"), "B"))
+ assert.False(t, su.HasPrefix(123, "B"))
+}
diff --git a/modules/templates/util_test.go b/modules/templates/util_test.go
new file mode 100644
index 0000000..79aaba4
--- /dev/null
+++ b/modules/templates/util_test.go
@@ -0,0 +1,79 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDict(t *testing.T) {
+ type M map[string]any
+ cases := []struct {
+ args []any
+ want map[string]any
+ }{
+ {[]any{"a", 1, "b", 2}, M{"a": 1, "b": 2}},
+ {[]any{".", M{"base": 1}, "b", 2}, M{"base": 1, "b": 2}},
+ {[]any{"a", 1, ".", M{"extra": 2}}, M{"a": 1, "extra": 2}},
+ {[]any{"a", 1, ".", map[string]int{"int": 2}}, M{"a": 1, "int": 2}},
+ {[]any{".", nil, "b", 2}, M{"b": 2}},
+ }
+
+ for _, c := range cases {
+ got, err := dict(c.args...)
+ require.NoError(t, err)
+ assert.EqualValues(t, c.want, got)
+ }
+
+ bads := []struct {
+ args []any
+ }{
+ {[]any{"a", 1, "b"}},
+ {[]any{1}},
+ {[]any{struct{}{}}},
+ }
+ for _, c := range bads {
+ _, err := dict(c.args...)
+ require.Error(t, err)
+ }
+}
+
+func TestUtils(t *testing.T) {
+ execTmpl := func(code string, data any) string {
+ tmpl := template.New("test")
+ tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+ template.Must(tmpl.Parse(code))
+ w := &strings.Builder{}
+ require.NoError(t, tmpl.Execute(w, data))
+ return w.String()
+ }
+
+ actual := execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "a"})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "x"})
+ assert.Equal(t, "false", actual)
+
+ actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []int64{1, 2}, "Value": int64(2)})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "b"})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
+ assert.Equal(t, "false", actual)
+
+ tmpl := template.New("test")
+ tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+ template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
+ // error is like this: `template: test:1:12: executing "test" at <SliceUtils.Contains>: error calling Contains: ...`
+ err := tmpl.Execute(io.Discard, map[string]any{"Slice": struct{}{}})
+ require.ErrorContains(t, err, "invalid type, expected slice or array")
+}
diff --git a/modules/templates/vars/vars.go b/modules/templates/vars/vars.go
new file mode 100644
index 0000000..cc9d0e9
--- /dev/null
+++ b/modules/templates/vars/vars.go
@@ -0,0 +1,92 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vars
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// ErrWrongSyntax represents a wrong syntax with a template
+type ErrWrongSyntax struct {
+ Template string
+}
+
+func (err ErrWrongSyntax) Error() string {
+ return fmt.Sprintf("wrong syntax found in %s", err.Template)
+}
+
+// ErrVarMissing represents an error that no matched variable
+type ErrVarMissing struct {
+ Template string
+ Var string
+}
+
+func (err ErrVarMissing) Error() string {
+ return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template)
+}
+
+// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors
+// if error occurs, the error part doesn't change and is returned as it is.
+func Expand(template string, vars map[string]string) (string, error) {
+ // in the future, if necessary, we can introduce some escape-char,
+ // for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'.
+ var buf strings.Builder
+ var err error
+
+ posBegin := 0
+ strLen := len(template)
+ for posBegin < strLen {
+ // find the next `{`
+ pos := strings.IndexByte(template[posBegin:], '{')
+ if pos == -1 {
+ buf.WriteString(template[posBegin:])
+ break
+ }
+
+ // copy texts between vars
+ buf.WriteString(template[posBegin : posBegin+pos])
+
+ // find the var between `{` and `}`/end
+ posBegin += pos
+ posEnd := posBegin + 1
+ for posEnd < strLen {
+ if template[posEnd] == '}' {
+ posEnd++
+ break
+ } // in the future, if we need to support escape chars, we can do: if (isEscapeChar) { posEnd+=2 }
+ posEnd++
+ }
+
+ // the var part, it can be "{", "{}", "{..." or or "{...}"
+ part := template[posBegin:posEnd]
+ posBegin = posEnd
+ if part == "{}" || part[len(part)-1] != '}' {
+ // treat "{}" or "{..." as error
+ err = ErrWrongSyntax{Template: template}
+ buf.WriteString(part)
+ } else {
+ // now we get a valid key "{...}"
+ key := part[1 : len(part)-1]
+ keyFirst, _ := utf8.DecodeRuneInString(key)
+ if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) {
+ // the if key doesn't start with a letter, then we do not treat it as a var now
+ buf.WriteString(part)
+ } else {
+ // look up in the map
+ if val, ok := vars[key]; ok {
+ buf.WriteString(val)
+ } else {
+ // write the non-existing var as it is
+ buf.WriteString(part)
+ err = ErrVarMissing{Template: template, Var: key}
+ }
+ }
+ }
+ }
+
+ return buf.String(), err
+}
diff --git a/modules/templates/vars/vars_test.go b/modules/templates/vars/vars_test.go
new file mode 100644
index 0000000..c543422
--- /dev/null
+++ b/modules/templates/vars/vars_test.go
@@ -0,0 +1,72 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vars
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExpandVars(t *testing.T) {
+ kases := []struct {
+ tmpl string
+ data map[string]string
+ out string
+ error bool
+ }{
+ {
+ tmpl: "{a}",
+ data: map[string]string{
+ "a": "1",
+ },
+ out: "1",
+ },
+ {
+ tmpl: "expand {a}, {b} and {c}, with non-var { } {#}",
+ data: map[string]string{
+ "a": "1",
+ "b": "2",
+ "c": "3",
+ },
+ out: "expand 1, 2 and 3, with non-var { } {#}",
+ },
+ {
+ tmpl: "中文内容 {一}, {二} 和 {三} 中文结尾",
+ data: map[string]string{
+ "一": "11",
+ "二": "22",
+ "三": "33",
+ },
+ out: "中文内容 11, 22 和 33 中文结尾",
+ },
+ {
+ tmpl: "expand {{a}, {b} and {c}",
+ data: map[string]string{
+ "a": "foo",
+ "b": "bar",
+ },
+ out: "expand {{a}, bar and {c}",
+ error: true,
+ },
+ {
+ tmpl: "expand } {} and {",
+ out: "expand } {} and {",
+ error: true,
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.tmpl, func(t *testing.T) {
+ res, err := Expand(kase.tmpl, kase.data)
+ assert.EqualValues(t, kase.out, res)
+ if kase.error {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/modules/test/logchecker.go b/modules/test/logchecker.go
new file mode 100644
index 0000000..0f12257
--- /dev/null
+++ b/modules/test/logchecker.go
@@ -0,0 +1,107 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+type LogChecker struct {
+ *log.EventWriterBaseImpl
+
+ filterMessages []string
+ filtered []bool
+
+ stopMark string
+ stopped bool
+
+ mu sync.Mutex
+}
+
+func (lc *LogChecker) Run(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case event, ok := <-lc.Queue:
+ if !ok {
+ return
+ }
+ lc.checkLogEvent(event)
+ }
+ }
+}
+
+func (lc *LogChecker) checkLogEvent(event *log.EventFormatted) {
+ lc.mu.Lock()
+ defer lc.mu.Unlock()
+ for i, msg := range lc.filterMessages {
+ if strings.Contains(event.Origin.MsgSimpleText, msg) {
+ lc.filtered[i] = true
+ }
+ }
+ if strings.Contains(event.Origin.MsgSimpleText, lc.stopMark) {
+ lc.stopped = true
+ }
+}
+
+var checkerIndex int64
+
+func NewLogChecker(namePrefix string, level log.Level) (logChecker *LogChecker, cancel func()) {
+ logger := log.GetManager().GetLogger(namePrefix)
+ newCheckerIndex := atomic.AddInt64(&checkerIndex, 1)
+ writerName := namePrefix + "-" + fmt.Sprint(newCheckerIndex)
+
+ lc := &LogChecker{}
+ lc.EventWriterBaseImpl = log.NewEventWriterBase(writerName, "test-log-checker", log.WriterMode{
+ Level: level,
+ })
+ logger.AddWriters(lc)
+ return lc, func() { _ = logger.RemoveWriter(writerName) }
+}
+
+// Filter will make the `Check` function to check if these logs are outputted.
+func (lc *LogChecker) Filter(msgs ...string) *LogChecker {
+ lc.mu.Lock()
+ defer lc.mu.Unlock()
+ lc.filterMessages = make([]string, len(msgs))
+ copy(lc.filterMessages, msgs)
+ lc.filtered = make([]bool, len(lc.filterMessages))
+ return lc
+}
+
+func (lc *LogChecker) StopMark(msg string) *LogChecker {
+ lc.mu.Lock()
+ defer lc.mu.Unlock()
+ lc.stopMark = msg
+ lc.stopped = false
+ return lc
+}
+
+// Check returns the filtered slice and whether the stop mark is reached.
+func (lc *LogChecker) Check(d time.Duration) (filtered []bool, stopped bool) {
+ stop := time.Now().Add(d)
+
+ for {
+ lc.mu.Lock()
+ stopped = lc.stopped
+ lc.mu.Unlock()
+
+ if time.Now().After(stop) || stopped {
+ lc.mu.Lock()
+ f := make([]bool, len(lc.filtered))
+ copy(f, lc.filtered)
+ lc.mu.Unlock()
+ return f, stopped
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/modules/test/logchecker_test.go b/modules/test/logchecker_test.go
new file mode 100644
index 0000000..0f410fe
--- /dev/null
+++ b/modules/test/logchecker_test.go
@@ -0,0 +1,58 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package test
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLogCheckerInfo(t *testing.T) {
+ lc, cleanup := NewLogChecker(log.DEFAULT, log.INFO)
+ defer cleanup()
+
+ lc.Filter("First", "Third").StopMark("End")
+ log.Info("test")
+
+ filtered, stopped := lc.Check(100 * time.Millisecond)
+ assert.ElementsMatch(t, []bool{false, false}, filtered)
+ assert.False(t, stopped)
+
+ log.Info("First")
+ log.Debug("Third")
+ filtered, stopped = lc.Check(100 * time.Millisecond)
+ assert.ElementsMatch(t, []bool{true, false}, filtered)
+ assert.False(t, stopped)
+
+ log.Info("Second")
+ log.Debug("Third")
+ filtered, stopped = lc.Check(100 * time.Millisecond)
+ assert.ElementsMatch(t, []bool{true, false}, filtered)
+ assert.False(t, stopped)
+
+ log.Info("Third")
+ filtered, stopped = lc.Check(100 * time.Millisecond)
+ assert.ElementsMatch(t, []bool{true, true}, filtered)
+ assert.False(t, stopped)
+
+ log.Info("End")
+ filtered, stopped = lc.Check(100 * time.Millisecond)
+ assert.ElementsMatch(t, []bool{true, true}, filtered)
+ assert.True(t, stopped)
+}
+
+func TestLogCheckerDebug(t *testing.T) {
+ lc, cleanup := NewLogChecker(log.DEFAULT, log.DEBUG)
+ defer cleanup()
+
+ lc.StopMark("End")
+
+ log.Debug("End")
+ _, stopped := lc.Check(100 * time.Millisecond)
+ assert.True(t, stopped)
+}
diff --git a/modules/test/utils.go b/modules/test/utils.go
new file mode 100644
index 0000000..3d884b6
--- /dev/null
+++ b/modules/test/utils.go
@@ -0,0 +1,48 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+// RedirectURL returns the redirect URL of a http response.
+// It also works for JSONRedirect: `{"redirect": "..."}`
+func RedirectURL(resp http.ResponseWriter) string {
+ loc := resp.Header().Get("Location")
+ if loc != "" {
+ return loc
+ }
+ if r, ok := resp.(*httptest.ResponseRecorder); ok {
+ m := map[string]any{}
+ err := json.Unmarshal(r.Body.Bytes(), &m)
+ if err == nil {
+ if loc, ok := m["redirect"].(string); ok {
+ return loc
+ }
+ }
+ }
+ return ""
+}
+
+func IsNormalPageCompleted(s string) bool {
+ return strings.Contains(s, `<footer class="page-footer"`) && strings.Contains(s, `</html>`)
+}
+
+// use for global variables only
+func MockVariableValue[T any](p *T, v T) (reset func()) {
+ old := *p
+ *p = v
+ return func() { *p = old }
+}
+
+// use for global variables only
+func MockProtect[T any](p *T) (reset func()) {
+ old := *p
+ return func() { *p = old }
+}
diff --git a/modules/test/utils_test.go b/modules/test/utils_test.go
new file mode 100644
index 0000000..a3be74e
--- /dev/null
+++ b/modules/test/utils_test.go
@@ -0,0 +1,18 @@
+// Copyright 2024 The Forgejo Authors
+// SPDX-License-Identifier: MIT
+
+package test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMockProtect(t *testing.T) {
+ mockable := "original"
+ restore := MockProtect(&mockable)
+ mockable = "tainted"
+ restore()
+ assert.Equal(t, "original", mockable)
+}
diff --git a/modules/testlogger/testlogger.go b/modules/testlogger/testlogger.go
new file mode 100644
index 0000000..95cbb86
--- /dev/null
+++ b/modules/testlogger/testlogger.go
@@ -0,0 +1,578 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package testlogger
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/queue"
+)
+
+var (
+ prefix string
+ SlowTest = 10 * time.Second
+ SlowFlush = 5 * time.Second
+)
+
+var WriterCloser = &testLoggerWriterCloser{}
+
+type testLoggerWriterCloser struct {
+ sync.RWMutex
+ t []testing.TB
+ errs []error // stack of error, parallel to the stack of testing.TB
+ err error // fallback if the stack is empty
+}
+
+func (w *testLoggerWriterCloser) pushT(t testing.TB) {
+ w.Lock()
+ w.t = append(w.t, t)
+ w.errs = append(w.errs, nil)
+ w.Unlock()
+}
+
+func (w *testLoggerWriterCloser) Log(level log.Level, msg string) {
+ msg = strings.TrimSpace(msg)
+
+ w.printMsg(msg)
+ if level >= log.ERROR {
+ w.recordError(msg)
+ }
+}
+
+// list of error message which will not fail the test
+// ideally this list should be empty, however ensuring that it does not grow
+// is already a good first step.
+var ignoredErrorMessage = []string{
+ // only seen on mysql tests https://codeberg.org/forgejo/forgejo/pulls/2657#issuecomment-1693055
+ `table columns using inconsistent collation, they should use "utf8mb4_0900_ai_ci". Please go to admin panel Self Check page`,
+
+ // TestPullWIPConvertSidebar
+ `:PullRequestPushCommits() [E] comment.LoadIssue: issue does not exist [id:`,
+
+ // TestAPIDeleteReleaseByTagName
+ // action notification were a commit cannot be computed (because the commit got deleted)
+ `Notify() [E] an error occurred while executing the DeleteRelease actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/release-tag, rel_path: ]`,
+ `Notify() [E] an error occurred while executing the PushCommits actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/release-tag, rel_path: ]`,
+
+ // TestAPIRepoTags
+ `Notify() [E] an error occurred while executing the DeleteRelease actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/gitea/22, rel_path: ]`,
+ `Notify() [E] an error occurred while executing the PushCommits actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/gitea/22, rel_path: ]`,
+
+ // TestAPIDeleteTagByName
+ `Notify() [E] an error occurred while executing the DeleteRelease actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/delete-tag, rel_path: ]`,
+ `Notify() [E] an error occurred while executing the PushCommits actions method: gitRepo.GetCommit: object does not exist [id: refs/tags/delete-tag, rel_path: ]`,
+
+ // TestAPIGenerateRepo
+ `Notify() [E] an error occurred while executing the CreateRepository actions method: gitRepo.GetCommit: object does not exist [id: , rel_path: ]`,
+
+ // TestAPIPullUpdateByRebase
+ `:testPR() [E] Unable to GetPullRequestByID[`,
+ `:PullRequestSynchronized() [E] LoadAttributes: getRepositoryByID `,
+ `:PullRequestSynchronized() [E] pr.Issue.LoadRepo: getRepositoryByID [`,
+ `:handler() [E] Was unable to create issue notification: issue does not exist [`,
+ `:func1() [E] PullRequestList.LoadAttributes: issues and prs may be not in sync: cannot find issue`,
+ `:func1() [E] checkForInvalidation: GetRepositoryByIDCtx: repository does not exist `,
+
+ // TestAPIPullReview
+ `PullRequestReview() [E] Unsupported review webhook type`,
+
+ // TestAPIPullReviewRequest
+ `ToAPIPullRequest() [E] unable to resolve PR head ref`,
+
+ // TestAPILFSUpload
+ `Put() [E] Whilst putting LFS OID[ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb]: Failed to copy to tmpPath: ca/97/8112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb Error: content size does not match`,
+ `[E] Error putting LFS MetaObject [ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb] into content store. Error: content size does not match`,
+ `UploadHandler() [E] Upload does not match LFS MetaObject [ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb]. Error: content size does not match`,
+ `Put() [E] Whilst putting LFS OID[2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a]: Failed to copy to tmpPath: 25/81/dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a Error: content hash does not match OID`,
+ `[E] Error putting LFS MetaObject [2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a] into content store. Error: content hash does not match OID`,
+ `UploadHandler() [E] Upload does not match LFS MetaObject [2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a]. Error: content hash does not match OID`,
+ `UploadHandler() [E] Upload does not match LFS MetaObject [83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4]. Error: content size does not match`,
+
+ // TestAPILFSVerify
+ `getAuthenticatedMeta() [E] Unable to get LFS OID[fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042] Error: LFS Meta object does not exist`,
+
+ // TestAPIUpdateOrgAvatar
+ `UpdateAvatar() [E] UploadAvatar: image.DecodeConfig: image: unknown format`,
+
+ // TestGetAttachment
+ `/data/attachments/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18: no such file or directory`,
+
+ // TestBlockUser
+ `BlockedUsersUnblock() [E] IsOrganization: org3 is an organization not a user`,
+ `BlockedUsersBlock() [E] IsOrganization: org3 is an organization not a user`,
+ `Action() [E] Cannot perform this action on an organization "unblock"`,
+ `Action() [E] Cannot perform this action on an organization "block"`,
+
+ // TestBlockActions
+ `/gitea-repositories/user10/repo7.git Error: no such file or directory`,
+
+ // TestRebuildCargo
+ `RebuildCargoIndex() [E] RebuildIndex failed: GetRepositoryByOwnerAndName: repository does not exist [id: 0, uid: 0, owner_name: user2, name: _cargo-index]`,
+
+ // TestDangerZoneConfirmation/Convert_fork/Fail
+ `/gitea-repositories/user20/big_test_public_fork_7.git Error: no such file or directory`,
+ // TestGitSmartHTTP
+ `:sendFile() [E] request file path contains invalid path: objects/info/..\..\..\..\custom\conf\app.ini`,
+ // TestGit/HTTP/BranchProtectMerge
+ `:SSHLog() [E] ssh: Not allowed to push to protected branch protected. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestGit/HTTP/BranchProtectMerge
+ `:SSHLog() [E] ssh: Not allowed to push to protected branch protected. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestGit/HTTP/BranchProtectMerge
+ `:SSHLog() [E] ssh: branch protected is protected from force push. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestGit/HTTP/MergeFork/CreatePRAndMerge
+ `:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 1099 name: user2:master]`, // sqlite
+ "s/web/repo/branch.go:108:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 10000 name: user2:master]", // mysql
+ "s/web/repo/branch.go:108:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 1060 name: user2:master]", // pgsql
+ // TestGit/HTTP/BranchProtectMerge
+ `:func1() [E] PushToBaseRepo: PushRejected Error: exit status 1 - remote: error: cannot lock ref`,
+ // TestGit/SSH/BranchProtectMerge
+ `:func1() [E] PushToBaseRepo: PushRejected Error: exit status 1 - remote: error: cannot lock ref`,
+ // TestGit/SSH/LFS/PushCommit/Little
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/PushCommit/Little
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/PushCommit/Big
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/PushCommit/Big
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/Locks
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/Locks
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/Locks
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/Locks
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/LFS/Locks
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/NoParams
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/NoParams
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/TitleOverride
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/TitleOverride
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/DescriptionOverride
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/PushParams/DescriptionOverride
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push/Fails
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push/Fails
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push/Succeeds
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push/Succeeds
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Force_push
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Branch_already_contains_commit
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull/Branch_already_contains_commit
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/CreateAgitFlowPull
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Not allowed to push to protected branch protected. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: branch protected is protected from force push. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/BranchProtectMerge
+ `:SSHLog() [E] ssh: Unknown git command. Unknown git command git-lfs-transfer`,
+ // TestGit/SSH/MergeFork/CreatePRAndMerge
+ `:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 1102 name: user2:master]`, // sqlite
+ "s/web/repo/branch.go:108:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 10003 name: user2:master]", // mysql
+ "s/web/repo/branch.go:108:DeleteBranchPost() [E] DeleteBranch: GetBranch: branch does not exist [repo_id: 1063 name: user2:master]", // pgsql
+ // TestGit/SSH/PushCreate
+ `:SSHLog() [E] ssh: Push to create is not enabled for users. ServCommand failed: internal API error response, status=403`,
+ // TestGit/SSH/PushCreate
+ `:SSHLog() [E] ssh: Cannot find repository: user2/repo-tmp-push-create-ssh. ServCommand failed: internal API error response, status=404`,
+ // TestGit/SSH/PushCreate
+ `:SSHLog() [E] ssh: Invalid repo name. Invalid repo name: invalid/repo-tmp-push-create-ssh`,
+ // TestIssueReaction
+ `:ChangeIssueReaction() [E] ChangeIssueReaction: '8ball' is not an allowed reaction`,
+ // TestIssuePinMove
+ `:IssuePinMove() [E] Issue does not belong to this repository`,
+ // TestLinksLogin
+ `:GetIssuesAllCommitStatus() [E] getAllCommitStatus: can't get commit statuses of pull [6]: object does not exist [id: refs/pull/2/head, rel_path: ]`,
+ // TestLinksLogin
+ `:GetIssuesAllCommitStatus() [E] getAllCommitStatus: can't get commit statuses of pull [6]: object does not exist [id: refs/pull/2/head, rel_path: ]`,
+ // TestLinksLogin
+ `:GetIssuesAllCommitStatus() [E] getAllCommitStatus: can't get commit statuses of pull [6]: object does not exist [id: refs/pull/2/head, rel_path: ]`,
+ // TestLinksLogin
+ `:GetIssuesAllCommitStatus() [E] Cannot open git repository <Repository 23:org17/big_test_public_4> for issue #1[20]. Error: no such file or directory`,
+ // TestMigrate
+ `] for OwnerID[2] failed: error while listing issues: token does not have at least one of required scope(s): [read:issue]`,
+ // TestMigrate
+ `:handler() [E] Run task failed: error while listing issues: token does not have at least one of required scope(s): [read:issue]`,
+ // TestMigrate
+ `] for OwnerID[2] failed: error while listing issues: token does not have at least one of required scope(s): [read:issue]`,
+ // TestMigrate
+ `:handler() [E] Run task failed: error while listing issues: token does not have at least one of required scope(s): [read:issue]`,
+ // TestMirrorPush
+ `:GetInfoRefs() [E] fork/exec /usr/bin/git: no such file or directory -`,
+
+ // TestOrgMembers
+ `:loadOrganizationOwners() [E] Organization does not have owner team: 25`,
+ // TestOrgMembers
+ `:loadOrganizationOwners() [E] Organization does not have owner team: 25`,
+ // TestOrgMembers
+ `:loadOrganizationOwners() [E] Organization does not have owner team: 25`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo50]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:handlerBranchSync() [E] syncRepoBranches [50] failed: no such file or directory`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo51]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:handlerBranchSync() [E] syncRepoBranches [51] failed: no such file or directory`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:SyncRepoBranches() [E] OpenRepository[user2/scoped_label]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestRecentlyPushed/unrelated_branches_are_not_shown
+ `:handlerBranchSync() [E] syncRepoBranches [55] failed: no such file or directory`,
+ // TestCantMergeConflict
+ "]user1/repo1#1[base...conflict]> Unable to merge tracking into base: Merge Conflict Error: exit status 1: \nAuto-merging README.md\nCONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result.",
+
+ // TestCantMergeUnrelated
+ `]user1/repo1#1[base...unrelated]> Unable to merge tracking into base: Merge UnrelatedHistories Error: exit status 128: fatal: refusing to merge unrelated histories`,
+ // TestCantFastForwardOnlyMergeDiverging
+ "]user1/repo1#1[master...diverging]> Unable to merge tracking into base: Merge DivergingFastForwardOnly Error: exit status 128: hint: Diverging branches can't be fast-forwarded, you need to either:\nhint: \nhint: \tgit merge --no-ff\nhint: \nhint: or:\nhint: \nhint: \tgit rebase\nhint: \nhint: Disable this message with \"git config advice.diverging false\"\nfatal: Not possible to fast-forward, aborting.",
+ // TestPullrequestReopen/Base_branch_deleted
+ `fatal: couldn't find remote ref base-branch`,
+ // TestPullrequestReopen/Head_branch_deleted
+ `]user2/reopen-base#1[base-branch...org26/reopen-head:head-branch]>]: branch does not exist [repo_id: 0 name: head-branch]`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo50]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [50] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo51]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [51] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user2/scoped_label]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [55] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ `:LoadBranches() [E] loadOneBranch() on repo #1, branch 'will-be-missing' failed: CountDivergingCommits: exit status 128 - fatal: bad revision 'master...refs/heads/will-be-missing'
+ - fatal: bad revision 'master...refs/heads/will-be-missing'`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo50]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [50] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user30/repo51]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [51] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ `:SyncRepoBranches() [E] OpenRepository[user2/scoped_label]: %!w(*errors.errorString=&{no such file or directory})`,
+ // TestDatabaseMissingABranch
+ `:handlerBranchSync() [E] syncRepoBranches [55] failed: no such file or directory`,
+ // TestDatabaseMissingABranch
+ "LoadBranches() [E] loadOneBranch() on repo #1, branch 'will-be-missing' failed: CountDivergingCommits: exit status 128 - fatal: bad revision 'master...refs/heads/will-be-missing'\n - fatal: bad revision 'master...refs/heads/will-be-missing'",
+
+ // TestCreateNewTagProtected/Git
+ `:SSHLog() [E] ssh: Tag v-2 is protected. HookPreReceive(last) failed: internal API error response, status=403`,
+ // TestMarkDownReadmeImage
+ `:checkOutdatedBranch() [E] GetBranch: branch does not exist [repo_id: 1 name: home-md-img-check]`,
+ // TestMarkDownReadmeImage
+ `:checkOutdatedBranch() [E] GetBranch: branch does not exist [repo_id: 1 name: home-md-img-check]`,
+ // TestMarkDownReadmeImageSubfolder
+ `:checkOutdatedBranch() [E] GetBranch: branch does not exist [repo_id: 1 name: sub-home-md-img-check]`,
+ // TestMarkDownReadmeImageSubfolder
+ `:checkOutdatedBranch() [E] GetBranch: branch does not exist [repo_id: 1 name: sub-home-md-img-check]`,
+
+ // TestKeyOnlyOneType
+ `:ssh-key-test-repo-push is not authorized to write to user2/ssh-key-test-repo. ServCommand failed: internal API error response, status=401`,
+
+ // TestPullRebase
+ "/gitea-repositories/user2/repo1.git' does not appear to be a git repository\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.",
+
+ // TestPullRebaseMerge
+ "]user2/repo1#3[master...branch2]>]: branch does not exist [repo_id: 0 name: branch2]",
+
+ // TestAuthorizeNoClientID
+ `TrString() [E] Missing translation "form.ResponseType"`,
+
+ // TestWebhookForms
+ `TrString() [E] Missing translation "form.AuthorizationHeader"`,
+ `TrString() [E] Missing translation "form.Channel"`,
+ `TrString() [E] Missing translation "form.ContentType"`,
+ `TrString() [E] Missing translation "form.HTTPMethod"`,
+ `TrString() [E] Missing translation "form.PayloadURL"`,
+
+ // TestRenameInvalidUsername
+ `TrString() [E] Missing translation "form.Name"`,
+
+ // TestDatabaseCollation
+ `[E] [Error SQL Query] INSERT INTO test_collation_tbl (txt) VALUES ('main') []`,
+}
+
+func (w *testLoggerWriterCloser) recordError(msg string) {
+ for _, s := range ignoredErrorMessage {
+ if strings.Contains(msg, s) {
+ return
+ }
+ }
+
+ w.Lock()
+ defer w.Unlock()
+
+ err := w.err
+ if len(w.errs) > 0 {
+ err = w.errs[len(w.errs)-1]
+ }
+
+ if len(w.t) > 0 {
+ // format error message to easily add it to the ignore list
+ msg = fmt.Sprintf("// %s\n\t%q,", w.t[len(w.t)-1].Name(), msg)
+ }
+
+ err = errors.Join(err, errors.New(msg))
+
+ if len(w.errs) > 0 {
+ w.errs[len(w.errs)-1] = err
+ } else {
+ w.err = err
+ }
+}
+
+func (w *testLoggerWriterCloser) printMsg(msg string) {
+ // There was a data race problem: the logger system could still try to output logs after the runner is finished.
+ // So we must ensure that the "t" in stack is still valid.
+ w.RLock()
+ defer w.RUnlock()
+
+ if len(w.t) > 0 {
+ t := w.t[len(w.t)-1]
+ t.Log(msg)
+ } else {
+ // if there is no running test, the log message should be outputted to console, to avoid losing important information.
+ // the "???" prefix is used to match the "===" and "+++" in PrintCurrentTest
+ fmt.Fprintln(os.Stdout, "??? [TestLogger]", msg)
+ }
+}
+
+func (w *testLoggerWriterCloser) popT() error {
+ w.Lock()
+ defer w.Unlock()
+
+ if len(w.t) > 0 {
+ w.t = w.t[:len(w.t)-1]
+ err := w.errs[len(w.errs)-1]
+ w.errs = w.errs[:len(w.errs)-1]
+ return err
+ }
+ return w.err
+}
+
+func (w *testLoggerWriterCloser) Reset() error {
+ w.Lock()
+ if len(w.t) > 0 {
+ for _, t := range w.t {
+ if t == nil {
+ continue
+ }
+ _, _ = fmt.Fprintf(os.Stdout, "Unclosed logger writer in test: %s", t.Name())
+ t.Errorf("Unclosed logger writer in test: %s", t.Name())
+ }
+ w.t = nil
+ w.errs = nil
+ }
+ err := w.err
+ w.err = nil
+ w.Unlock()
+ return err
+}
+
+// PrintCurrentTest prints the current test to os.Stdout
+func PrintCurrentTest(t testing.TB, skip ...int) func() {
+ t.Helper()
+ start := time.Now()
+ actualSkip := 1
+ if len(skip) > 0 {
+ actualSkip = skip[0] + 1
+ }
+ _, filename, line, _ := runtime.Caller(actualSkip)
+
+ if log.CanColorStdout {
+ _, _ = fmt.Fprintf(os.Stdout, "=== %s (%s:%d)\n", fmt.Formatter(log.NewColoredValue(t.Name())), strings.TrimPrefix(filename, prefix), line)
+ } else {
+ _, _ = fmt.Fprintf(os.Stdout, "=== %s (%s:%d)\n", t.Name(), strings.TrimPrefix(filename, prefix), line)
+ }
+ WriterCloser.pushT(t)
+ return func() {
+ took := time.Since(start)
+ if took > SlowTest {
+ if log.CanColorStdout {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s is a slow test (took %v)\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgYellow)), fmt.Formatter(log.NewColoredValue(took, log.Bold, log.FgYellow)))
+ } else {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s is a slow test (took %v)\n", t.Name(), took)
+ }
+ }
+ timer := time.AfterFunc(SlowFlush, func() {
+ if log.CanColorStdout {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s ... still flushing after %v ...\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgRed)), SlowFlush)
+ } else {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s ... still flushing after %v ...\n", t.Name(), SlowFlush)
+ }
+ })
+ if err := queue.GetManager().FlushAll(context.Background(), time.Minute); err != nil {
+ t.Errorf("Flushing queues failed with error %v", err)
+ }
+ timer.Stop()
+ flushTook := time.Since(start) - took
+ if flushTook > SlowFlush {
+ if log.CanColorStdout {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s had a slow clean-up flush (took %v)\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgRed)), fmt.Formatter(log.NewColoredValue(flushTook, log.Bold, log.FgRed)))
+ } else {
+ _, _ = fmt.Fprintf(os.Stdout, "+++ %s had a slow clean-up flush (took %v)\n", t.Name(), flushTook)
+ }
+ }
+
+ if err := WriterCloser.popT(); err != nil {
+ // disable test failure for now (too flacky)
+ _, _ = fmt.Fprintf(os.Stdout, "testlogger.go:recordError() FATAL ERROR: log.Error has been called: %v", err)
+ // t.Errorf("testlogger.go:recordError() FATAL ERROR: log.Error has been called: %v", err)
+ }
+ }
+}
+
+// Printf takes a format and args and prints the string to os.Stdout
+func Printf(format string, args ...any) {
+ if log.CanColorStdout {
+ for i := 0; i < len(args); i++ {
+ args[i] = log.NewColoredValue(args[i])
+ }
+ }
+ _, _ = fmt.Fprintf(os.Stdout, "\t"+format, args...)
+}
+
+// NewTestLoggerWriter creates a TestLogEventWriter as a log.LoggerProvider
+func NewTestLoggerWriter(name string, mode log.WriterMode) log.EventWriter {
+ w := &TestLogEventWriter{}
+ w.base = log.NewEventWriterBase(name, "test-log-writer", mode)
+ w.writer = WriterCloser
+ return w
+}
+
+// TestLogEventWriter is a logger which will write to the testing log
+type TestLogEventWriter struct {
+ base *log.EventWriterBaseImpl
+ writer *testLoggerWriterCloser
+}
+
+// Base implements log.EventWriter.
+func (t *TestLogEventWriter) Base() *log.EventWriterBaseImpl {
+ return t.base
+}
+
+// GetLevel implements log.EventWriter.
+func (t *TestLogEventWriter) GetLevel() log.Level {
+ return t.base.GetLevel()
+}
+
+// GetWriterName implements log.EventWriter.
+func (t *TestLogEventWriter) GetWriterName() string {
+ return t.base.GetWriterName()
+}
+
+// GetWriterType implements log.EventWriter.
+func (t *TestLogEventWriter) GetWriterType() string {
+ return t.base.GetWriterType()
+}
+
+// Run implements log.EventWriter.
+func (t *TestLogEventWriter) Run(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case event, ok := <-t.base.Queue:
+ if !ok {
+ return
+ }
+
+ var errorMsg string
+
+ switch msg := event.Msg.(type) {
+ case string:
+ errorMsg = msg
+ case []byte:
+ errorMsg = string(msg)
+ case io.WriterTo:
+ var buf bytes.Buffer
+ if _, err := msg.WriteTo(&buf); err != nil {
+ panic(err)
+ }
+ errorMsg = buf.String()
+ default:
+ errorMsg = fmt.Sprint(msg)
+ }
+ t.writer.Log(event.Origin.Level, errorMsg)
+ }
+ }
+}
+
+func init() {
+ const relFilePath = "modules/testlogger/testlogger.go"
+ _, filename, _, _ := runtime.Caller(0)
+ if !strings.HasSuffix(filename, relFilePath) {
+ panic("source code file path doesn't match expected: " + relFilePath)
+ }
+ prefix = strings.TrimSuffix(filename, relFilePath)
+}
diff --git a/modules/timeutil/datetime.go b/modules/timeutil/datetime.go
new file mode 100644
index 0000000..c089173
--- /dev/null
+++ b/modules/timeutil/datetime.go
@@ -0,0 +1,68 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "strings"
+ "time"
+)
+
+// DateTime renders an absolute time HTML element by datetime.
+func DateTime(format string, datetime any, extraAttrs ...string) template.HTML {
+ // TODO: remove the extraAttrs argument, it's not used in any call to DateTime
+
+ if p, ok := datetime.(*time.Time); ok {
+ datetime = *p
+ }
+ if p, ok := datetime.(*TimeStamp); ok {
+ datetime = *p
+ }
+ switch v := datetime.(type) {
+ case TimeStamp:
+ datetime = v.AsTime()
+ case int:
+ datetime = TimeStamp(v).AsTime()
+ case int64:
+ datetime = TimeStamp(v).AsTime()
+ }
+
+ var datetimeEscaped, textEscaped string
+ switch v := datetime.(type) {
+ case nil:
+ return "-"
+ case string:
+ datetimeEscaped = html.EscapeString(v)
+ textEscaped = datetimeEscaped
+ case time.Time:
+ if v.IsZero() || v.Unix() == 0 {
+ return "-"
+ }
+ datetimeEscaped = html.EscapeString(v.Format(time.RFC3339))
+ if format == "full" {
+ textEscaped = html.EscapeString(v.Format("2006-01-02 15:04:05 -07:00"))
+ } else {
+ textEscaped = html.EscapeString(v.Format("2006-01-02"))
+ }
+ default:
+ panic(fmt.Sprintf("Unsupported time type %T", datetime))
+ }
+
+ attrs := make([]string, 0, 10+len(extraAttrs))
+ attrs = append(attrs, extraAttrs...)
+ attrs = append(attrs, `weekday=""`, `year="numeric"`)
+
+ switch format {
+ case "short", "long": // date only
+ attrs = append(attrs, `month="`+format+`"`, `day="numeric"`)
+ return template.HTML(fmt.Sprintf(`<absolute-date %s date="%s">%s</absolute-date>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
+ case "full": // full date including time
+ attrs = append(attrs, `format="datetime"`, `month="short"`, `day="numeric"`, `hour="numeric"`, `minute="numeric"`, `second="numeric"`, `data-tooltip-content`, `data-tooltip-interactive="true"`)
+ return template.HTML(fmt.Sprintf(`<relative-time %s datetime="%s">%s</relative-time>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
+ default:
+ panic(fmt.Sprintf("Unsupported format %s", format))
+ }
+}
diff --git a/modules/timeutil/datetime_test.go b/modules/timeutil/datetime_test.go
new file mode 100644
index 0000000..ac2ce35
--- /dev/null
+++ b/modules/timeutil/datetime_test.go
@@ -0,0 +1,47 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDateTime(t *testing.T) {
+ testTz, _ := time.LoadLocation("America/New_York")
+ defer test.MockVariableValue(&setting.DefaultUILocation, testTz)()
+
+ refTimeStr := "2018-01-01T00:00:00Z"
+ refDateStr := "2018-01-01"
+ refTime, _ := time.Parse(time.RFC3339, refTimeStr)
+ refTimeStamp := TimeStamp(refTime.Unix())
+
+ assert.EqualValues(t, "-", DateTime("short", nil))
+ assert.EqualValues(t, "-", DateTime("short", 0))
+ assert.EqualValues(t, "-", DateTime("short", time.Time{}))
+ assert.EqualValues(t, "-", DateTime("short", TimeStamp(0)))
+
+ actual := DateTime("short", "invalid")
+ assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="invalid">invalid</absolute-date>`, actual)
+
+ actual = DateTime("short", refTimeStr)
+ assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2018-01-01T00:00:00Z">2018-01-01T00:00:00Z</absolute-date>`, actual)
+
+ actual = DateTime("short", refTime)
+ assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2018-01-01T00:00:00Z">2018-01-01</absolute-date>`, actual)
+
+ actual = DateTime("short", refDateStr)
+ assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2018-01-01">2018-01-01</absolute-date>`, actual)
+
+ actual = DateTime("short", refTimeStamp)
+ assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2017-12-31T19:00:00-05:00">2017-12-31</absolute-date>`, actual)
+
+ actual = DateTime("full", refTimeStamp)
+ assert.EqualValues(t, `<relative-time weekday="" year="numeric" format="datetime" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" data-tooltip-content data-tooltip-interactive="true" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
+}
diff --git a/modules/timeutil/executable.go b/modules/timeutil/executable.go
new file mode 100644
index 0000000..57ae8b2
--- /dev/null
+++ b/modules/timeutil/executable.go
@@ -0,0 +1,50 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+var (
+ executablModTime = time.Now()
+ executablModTimeOnce sync.Once
+)
+
+// GetExecutableModTime get executable file modified time of current process.
+func GetExecutableModTime() time.Time {
+ executablModTimeOnce.Do(func() {
+ exePath, err := os.Executable()
+ if err != nil {
+ log.Error("os.Executable: %v", err)
+ return
+ }
+
+ exePath, err = filepath.Abs(exePath)
+ if err != nil {
+ log.Error("filepath.Abs: %v", err)
+ return
+ }
+
+ exePath, err = filepath.EvalSymlinks(exePath)
+ if err != nil {
+ log.Error("filepath.EvalSymlinks: %v", err)
+ return
+ }
+
+ st, err := os.Stat(exePath)
+ if err != nil {
+ log.Error("os.Stat: %v", err)
+ return
+ }
+
+ executablModTime = st.ModTime()
+ })
+ return executablModTime
+}
diff --git a/modules/timeutil/since.go b/modules/timeutil/since.go
new file mode 100644
index 0000000..dba42c7
--- /dev/null
+++ b/modules/timeutil/since.go
@@ -0,0 +1,145 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+)
+
+// Seconds-based time units
+const (
+ Minute = 60
+ Hour = 60 * Minute
+ Day = 24 * Hour
+ Week = 7 * Day
+ Month = 30 * Day
+ Year = 12 * Month
+)
+
+func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) {
+ var diffStr string
+ switch {
+ case diff <= 0:
+ diff = 0
+ diffStr = lang.TrString("tool.now")
+ case diff < 2:
+ diff = 0
+ diffStr = lang.TrString("tool.1s")
+ case diff < 1*Minute:
+ diffStr = lang.TrString("tool.seconds", diff)
+ diff = 0
+
+ case diff < 2*Minute:
+ diff -= 1 * Minute
+ diffStr = lang.TrString("tool.1m")
+ case diff < 1*Hour:
+ diffStr = lang.TrString("tool.minutes", diff/Minute)
+ diff -= diff / Minute * Minute
+
+ case diff < 2*Hour:
+ diff -= 1 * Hour
+ diffStr = lang.TrString("tool.1h")
+ case diff < 1*Day:
+ diffStr = lang.TrString("tool.hours", diff/Hour)
+ diff -= diff / Hour * Hour
+
+ case diff < 2*Day:
+ diff -= 1 * Day
+ diffStr = lang.TrString("tool.1d")
+ case diff < 1*Week:
+ diffStr = lang.TrString("tool.days", diff/Day)
+ diff -= diff / Day * Day
+
+ case diff < 2*Week:
+ diff -= 1 * Week
+ diffStr = lang.TrString("tool.1w")
+ case diff < 1*Month:
+ diffStr = lang.TrString("tool.weeks", diff/Week)
+ diff -= diff / Week * Week
+
+ case diff < 2*Month:
+ diff -= 1 * Month
+ diffStr = lang.TrString("tool.1mon")
+ case diff < 1*Year:
+ diffStr = lang.TrString("tool.months", diff/Month)
+ diff -= diff / Month * Month
+
+ case diff < 2*Year:
+ diff -= 1 * Year
+ diffStr = lang.TrString("tool.1y")
+ default:
+ diffStr = lang.TrString("tool.years", diff/Year)
+ diff -= (diff / Year) * Year
+ }
+ return diff, diffStr
+}
+
+// MinutesToFriendly returns a user friendly string with number of minutes
+// converted to hours and minutes.
+func MinutesToFriendly(minutes int, lang translation.Locale) string {
+ duration := time.Duration(minutes) * time.Minute
+ return TimeSincePro(time.Now().Add(-duration), lang)
+}
+
+// TimeSincePro calculates the time interval and generate full user-friendly string.
+func TimeSincePro(then time.Time, lang translation.Locale) string {
+ return timeSincePro(then, time.Now(), lang)
+}
+
+func timeSincePro(then, now time.Time, lang translation.Locale) string {
+ diff := now.Unix() - then.Unix()
+
+ if then.After(now) {
+ return lang.TrString("tool.future")
+ }
+ if diff == 0 {
+ return lang.TrString("tool.now")
+ }
+
+ var timeStr, diffStr string
+ for {
+ if diff == 0 {
+ break
+ }
+
+ diff, diffStr = computeTimeDiffFloor(diff, lang)
+ timeStr += ", " + diffStr
+ }
+ return strings.TrimPrefix(timeStr, ", ")
+}
+
+func timeSinceUnix(then, now time.Time, _ translation.Locale) template.HTML {
+ friendlyText := then.Format("2006-01-02 15:04:05 -07:00")
+
+ // document: https://github.com/github/relative-time-element
+ attrs := `tense="past"`
+ isFuture := now.Before(then)
+ if isFuture {
+ attrs = `tense="future"`
+ }
+
+ // declare data-tooltip-content attribute to switch from "title" tooltip to "tippy" tooltip
+ htm := fmt.Sprintf(`<relative-time prefix="" %s datetime="%s" data-tooltip-content data-tooltip-interactive="true">%s</relative-time>`,
+ attrs, then.Format(time.RFC3339), friendlyText)
+ return template.HTML(htm)
+}
+
+// TimeSince renders relative time HTML given a time.Time
+func TimeSince(then time.Time, lang translation.Locale) template.HTML {
+ if setting.UI.PreferredTimestampTense == "absolute" {
+ return DateTime("full", then)
+ }
+ return timeSinceUnix(then, time.Now(), lang)
+}
+
+// TimeSinceUnix renders relative time HTML given a TimeStamp
+func TimeSinceUnix(then TimeStamp, lang translation.Locale) template.HTML {
+ return TimeSince(then.AsLocalTime(), lang)
+}
diff --git a/modules/timeutil/since_test.go b/modules/timeutil/since_test.go
new file mode 100644
index 0000000..40fefe8
--- /dev/null
+++ b/modules/timeutil/since_test.go
@@ -0,0 +1,87 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+)
+
+var BaseDate time.Time
+
+// time durations
+const (
+ DayDur = 24 * time.Hour
+ WeekDur = 7 * DayDur
+ MonthDur = 30 * DayDur
+ YearDur = 12 * MonthDur
+)
+
+func TestMain(m *testing.M) {
+ setting.StaticRootPath = "../../"
+ setting.Names = []string{"english"}
+ setting.Langs = []string{"en-US"}
+ // setup
+ translation.InitLocales(context.Background())
+ BaseDate = time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+ // run the tests
+ retVal := m.Run()
+
+ os.Exit(retVal)
+}
+
+func TestTimeSincePro(t *testing.T) {
+ assert.Equal(t, "now", timeSincePro(BaseDate, BaseDate, translation.NewLocale("en-US")))
+
+ // test that a difference of `diff` yields the expected string
+ test := func(expected string, diff time.Duration) {
+ actual := timeSincePro(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US"))
+ assert.Equal(t, expected, actual)
+ assert.Equal(t, "future", timeSincePro(BaseDate.Add(diff), BaseDate, translation.NewLocale("en-US")))
+ }
+ test("1 second", time.Second)
+ test("2 seconds", 2*time.Second)
+ test("1 minute", time.Minute)
+ test("1 minute, 1 second", time.Minute+time.Second)
+ test("1 minute, 59 seconds", time.Minute+59*time.Second)
+ test("2 minutes", 2*time.Minute)
+ test("1 hour", time.Hour)
+ test("1 hour, 1 second", time.Hour+time.Second)
+ test("1 hour, 59 minutes, 59 seconds", time.Hour+59*time.Minute+59*time.Second)
+ test("2 hours", 2*time.Hour)
+ test("1 day", DayDur)
+ test("1 day, 23 hours, 59 minutes, 59 seconds",
+ DayDur+23*time.Hour+59*time.Minute+59*time.Second)
+ test("2 days", 2*DayDur)
+ test("1 week", WeekDur)
+ test("2 weeks", 2*WeekDur)
+ test("1 month", MonthDur)
+ test("3 months", 3*MonthDur)
+ test("1 year", YearDur)
+ test("2 years, 3 months, 1 week, 2 days, 4 hours, 12 minutes, 17 seconds",
+ 2*YearDur+3*MonthDur+WeekDur+2*DayDur+4*time.Hour+
+ 12*time.Minute+17*time.Second)
+}
+
+func TestMinutesToFriendly(t *testing.T) {
+ // test that a number of minutes yields the expected string
+ test := func(expected string, minutes int) {
+ actual := MinutesToFriendly(minutes, translation.NewLocale("en-US"))
+ assert.Equal(t, expected, actual)
+ }
+ test("1 minute", 1)
+ test("2 minutes", 2)
+ test("1 hour", 60)
+ test("1 hour, 1 minute", 61)
+ test("1 hour, 2 minutes", 62)
+ test("2 hours", 120)
+}
diff --git a/modules/timeutil/timestamp.go b/modules/timeutil/timestamp.go
new file mode 100644
index 0000000..27a80b6
--- /dev/null
+++ b/modules/timeutil/timestamp.go
@@ -0,0 +1,100 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// TimeStamp defines a timestamp
+type TimeStamp int64
+
+var (
+ // mockNow is NOT concurrency-safe!!
+ mockNow time.Time
+
+ // Used for IsZero, to check if timestamp is the zero time instant.
+ timeZeroUnix = time.Time{}.Unix()
+)
+
+// MockSet sets the time to a mocked time.Time
+func MockSet(now time.Time) {
+ mockNow = now
+}
+
+// MockUnset will unset the mocked time.Time
+func MockUnset() {
+ mockNow = time.Time{}
+}
+
+// TimeStampNow returns now int64
+func TimeStampNow() TimeStamp {
+ if !mockNow.IsZero() {
+ return TimeStamp(mockNow.Unix())
+ }
+ return TimeStamp(time.Now().Unix())
+}
+
+// Add adds seconds and return sum
+func (ts TimeStamp) Add(seconds int64) TimeStamp {
+ return ts + TimeStamp(seconds)
+}
+
+// AddDuration adds time.Duration and return sum
+func (ts TimeStamp) AddDuration(interval time.Duration) TimeStamp {
+ return ts + TimeStamp(interval/time.Second)
+}
+
+// Year returns the time's year
+func (ts TimeStamp) Year() int {
+ return ts.AsTime().Year()
+}
+
+// AsTime convert timestamp as time.Time in Local locale
+func (ts TimeStamp) AsTime() (tm time.Time) {
+ return ts.AsTimeInLocation(setting.DefaultUILocation)
+}
+
+// AsLocalTime convert timestamp as time.Time in local location
+func (ts TimeStamp) AsLocalTime() time.Time {
+ return time.Unix(int64(ts), 0)
+}
+
+// AsTimeInLocation convert timestamp as time.Time in Local locale
+func (ts TimeStamp) AsTimeInLocation(loc *time.Location) time.Time {
+ return time.Unix(int64(ts), 0).In(loc)
+}
+
+// AsTimePtr convert timestamp as *time.Time in Local locale
+func (ts TimeStamp) AsTimePtr() *time.Time {
+ return ts.AsTimePtrInLocation(setting.DefaultUILocation)
+}
+
+// AsTimePtrInLocation convert timestamp as *time.Time in customize location
+func (ts TimeStamp) AsTimePtrInLocation(loc *time.Location) *time.Time {
+ tm := time.Unix(int64(ts), 0).In(loc)
+ return &tm
+}
+
+// Format formats timestamp as given format
+func (ts TimeStamp) Format(f string) string {
+ return ts.FormatInLocation(f, setting.DefaultUILocation)
+}
+
+// FormatInLocation formats timestamp as given format with spiecific location
+func (ts TimeStamp) FormatInLocation(f string, loc *time.Location) string {
+ return ts.AsTimeInLocation(loc).Format(f)
+}
+
+// FormatDate formats a date in YYYY-MM-DD
+func (ts TimeStamp) FormatDate() string {
+ return ts.Format("2006-01-02")
+}
+
+// IsZero is zero time
+func (ts TimeStamp) IsZero() bool {
+ return int64(ts) == 0 || int64(ts) == timeZeroUnix
+}
diff --git a/modules/timeutil/timestampnano.go b/modules/timeutil/timestampnano.go
new file mode 100644
index 0000000..4a9f795
--- /dev/null
+++ b/modules/timeutil/timestampnano.go
@@ -0,0 +1,28 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package timeutil
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// TimeStampNano is for nano time in database, do not use it unless there is a real requirement.
+type TimeStampNano int64
+
+// TimeStampNanoNow returns now nano int64
+func TimeStampNanoNow() TimeStampNano {
+ return TimeStampNano(time.Now().UnixNano())
+}
+
+// AsTime convert timestamp as time.Time in Local locale
+func (tsn TimeStampNano) AsTime() (tm time.Time) {
+ return tsn.AsTimeInLocation(setting.DefaultUILocation)
+}
+
+// AsTimeInLocation convert timestamp as time.Time in Local locale
+func (tsn TimeStampNano) AsTimeInLocation(loc *time.Location) time.Time {
+ return time.Unix(0, int64(tsn)).In(loc)
+}
diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go
new file mode 100644
index 0000000..7f64ccf
--- /dev/null
+++ b/modules/translation/i18n/errors.go
@@ -0,0 +1,13 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package i18n
+
+import (
+ "code.gitea.io/gitea/modules/util"
+)
+
+var (
+ ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist}
+ ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument}
+)
diff --git a/modules/translation/i18n/format.go b/modules/translation/i18n/format.go
new file mode 100644
index 0000000..e5e2218
--- /dev/null
+++ b/modules/translation/i18n/format.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package i18n
+
+import (
+ "fmt"
+ "reflect"
+)
+
+// Format formats provided arguments for a given translated message
+func Format(format string, args ...any) (msg string, err error) {
+ if len(args) == 0 {
+ return format, nil
+ }
+
+ fmtArgs := make([]any, 0, len(args))
+ for _, arg := range args {
+ val := reflect.ValueOf(arg)
+ if val.Kind() == reflect.Slice {
+ // Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f)
+ // but this is an unstable behavior.
+ //
+ // So we restrict the accepted arguments to either:
+ //
+ // 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
+ // 2. Tr(lang, key, args...) as Sprintf(msg, args...)
+ if len(args) == 1 {
+ for i := 0; i < val.Len(); i++ {
+ fmtArgs = append(fmtArgs, val.Index(i).Interface())
+ }
+ } else {
+ err = ErrUncertainArguments
+ break
+ }
+ } else {
+ fmtArgs = append(fmtArgs, arg)
+ }
+ }
+ return fmt.Sprintf(format, fmtArgs...), err
+}
diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go
new file mode 100644
index 0000000..1555cd9
--- /dev/null
+++ b/modules/translation/i18n/i18n.go
@@ -0,0 +1,50 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package i18n
+
+import (
+ "html/template"
+ "io"
+)
+
+var DefaultLocales = NewLocaleStore()
+
+type Locale interface {
+ // TrString translates a given key and arguments for a language
+ TrString(trKey string, trArgs ...any) string
+ // TrHTML translates a given key and arguments for a language, string arguments are escaped to HTML
+ TrHTML(trKey string, trArgs ...any) template.HTML
+ // HasKey reports if a locale has a translation for a given key
+ HasKey(trKey string) bool
+}
+
+// LocaleStore provides the functions common to all locale stores
+type LocaleStore interface {
+ io.Closer
+
+ // SetDefaultLang sets the default language to fall back to
+ SetDefaultLang(lang string)
+ // ListLangNameDesc provides paired slices of language names to descriptors
+ ListLangNameDesc() (names, desc []string)
+ // Locale return the locale for the provided language or the default language if not found
+ Locale(langName string) (Locale, bool)
+ // HasLang returns whether a given language is present in the store
+ HasLang(langName string) bool
+ // AddLocaleByIni adds a new language to the store
+ AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error
+}
+
+// ResetDefaultLocales resets the current default locales
+// NOTE: this is not synchronized
+func ResetDefaultLocales() {
+ if DefaultLocales != nil {
+ _ = DefaultLocales.Close()
+ }
+ DefaultLocales = NewLocaleStore()
+}
+
+// GetLocale returns the locale from the default locales
+func GetLocale(lang string) (Locale, bool) {
+ return DefaultLocales.Locale(lang)
+}
diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go
new file mode 100644
index 0000000..244f6ff
--- /dev/null
+++ b/modules/translation/i18n/i18n_test.go
@@ -0,0 +1,204 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package i18n
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLocaleStore(t *testing.T) {
+ testData1 := []byte(`
+.dot.name = Dot Name
+fmt = %[1]s %[2]s
+
+[section]
+sub = Sub String
+mixed = test value; <span style="color: red\; background: none;">%s</span>
+`)
+
+ testData2 := []byte(`
+fmt = %[2]s %[1]s
+
+[section]
+sub = Changed Sub String
+`)
+
+ ls := NewLocaleStore()
+ require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, nil))
+ require.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2, nil))
+ ls.SetDefaultLang("lang1")
+
+ lang1, _ := ls.Locale("lang1")
+ lang2, _ := ls.Locale("lang2")
+
+ result := lang1.TrString("fmt", "a", "b")
+ assert.Equal(t, "a b", result)
+
+ result = lang2.TrString("fmt", "a", "b")
+ assert.Equal(t, "b a", result)
+
+ result = lang1.TrString("section.sub")
+ assert.Equal(t, "Sub String", result)
+
+ result = lang2.TrString("section.sub")
+ assert.Equal(t, "Changed Sub String", result)
+
+ langNone, _ := ls.Locale("none")
+ result = langNone.TrString(".dot.name")
+ assert.Equal(t, "Dot Name", result)
+
+ result2 := lang2.TrHTML("section.mixed", "a&b")
+ assert.EqualValues(t, `test value; <span style="color: red; background: none;">a&amp;b</span>`, result2)
+
+ langs, descs := ls.ListLangNameDesc()
+ assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs)
+ assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs)
+
+ found := lang1.HasKey("no-such")
+ assert.False(t, found)
+ require.NoError(t, ls.Close())
+}
+
+func TestLocaleStoreMoreSource(t *testing.T) {
+ testData1 := []byte(`
+a=11
+b=12
+`)
+
+ testData2 := []byte(`
+b=21
+c=22
+`)
+
+ ls := NewLocaleStore()
+ require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, testData2))
+ lang1, _ := ls.Locale("lang1")
+ assert.Equal(t, "11", lang1.TrString("a"))
+ assert.Equal(t, "21", lang1.TrString("b"))
+ assert.Equal(t, "22", lang1.TrString("c"))
+}
+
+type stringerPointerReceiver struct {
+ s string
+}
+
+func (s *stringerPointerReceiver) String() string {
+ return s.s
+}
+
+type stringerStructReceiver struct {
+ s string
+}
+
+func (s stringerStructReceiver) String() string {
+ return s.s
+}
+
+type errorStructReceiver struct {
+ s string
+}
+
+func (e errorStructReceiver) Error() string {
+ return e.s
+}
+
+type errorPointerReceiver struct {
+ s string
+}
+
+func (e *errorPointerReceiver) Error() string {
+ return e.s
+}
+
+func TestLocaleWithTemplate(t *testing.T) {
+ ls := NewLocaleStore()
+ require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", []byte(`key=<a>%s</a>`), nil))
+ lang1, _ := ls.Locale("lang1")
+
+ tmpl := template.New("test").Funcs(template.FuncMap{"tr": lang1.TrHTML})
+ tmpl = template.Must(tmpl.Parse(`{{tr "key" .var}}`))
+
+ cases := []struct {
+ in any
+ want string
+ }{
+ {"<str>", "<a>&lt;str&gt;</a>"},
+ {[]byte("<bytes>"), "<a>[60 98 121 116 101 115 62]</a>"},
+ {template.HTML("<html>"), "<a><html></a>"},
+ {stringerPointerReceiver{"<stringerPointerReceiver>"}, "<a>{&lt;stringerPointerReceiver&gt;}</a>"},
+ {&stringerPointerReceiver{"<stringerPointerReceiver ptr>"}, "<a>&lt;stringerPointerReceiver ptr&gt;</a>"},
+ {stringerStructReceiver{"<stringerStructReceiver>"}, "<a>&lt;stringerStructReceiver&gt;</a>"},
+ {&stringerStructReceiver{"<stringerStructReceiver ptr>"}, "<a>&lt;stringerStructReceiver ptr&gt;</a>"},
+ {errorStructReceiver{"<errorStructReceiver>"}, "<a>&lt;errorStructReceiver&gt;</a>"},
+ {&errorStructReceiver{"<errorStructReceiver ptr>"}, "<a>&lt;errorStructReceiver ptr&gt;</a>"},
+ {errorPointerReceiver{"<errorPointerReceiver>"}, "<a>{&lt;errorPointerReceiver&gt;}</a>"},
+ {&errorPointerReceiver{"<errorPointerReceiver ptr>"}, "<a>&lt;errorPointerReceiver ptr&gt;</a>"},
+ }
+
+ buf := &strings.Builder{}
+ for _, c := range cases {
+ buf.Reset()
+ require.NoError(t, tmpl.Execute(buf, map[string]any{"var": c.in}))
+ assert.Equal(t, c.want, buf.String())
+ }
+}
+
+func TestLocaleStoreQuirks(t *testing.T) {
+ const nl = "\n"
+ q := func(q1, s string, q2 ...string) string {
+ return q1 + s + strings.Join(q2, "")
+ }
+ testDataList := []struct {
+ in string
+ out string
+ hint string
+ }{
+ {` xx`, `xx`, "simple, no quote"},
+ {`" xx"`, ` xx`, "simple, double-quote"},
+ {`' xx'`, ` xx`, "simple, single-quote"},
+ {"` xx`", ` xx`, "simple, back-quote"},
+
+ {`x\"y`, `x\"y`, "no unescape, simple"},
+ {q(`"`, `x\"y`, `"`), `"x\"y"`, "unescape, double-quote"},
+ {q(`'`, `x\"y`, `'`), `x\"y`, "no unescape, single-quote"},
+ {q("`", `x\"y`, "`"), `x\"y`, "no unescape, back-quote"},
+
+ {q(`"`, `x\"y`) + nl + "b=", `"x\"y`, "half open, double-quote"},
+ {q(`'`, `x\"y`) + nl + "b=", `'x\"y`, "half open, single-quote"},
+ {q("`", `x\"y`) + nl + "b=`", `x\"y` + nl + "b=", "half open, back-quote, multi-line"},
+
+ {`x ; y`, `x ; y`, "inline comment (;)"},
+ {`x # y`, `x # y`, "inline comment (#)"},
+ {`x \; y`, `x ; y`, `inline comment (\;)`},
+ {`x \# y`, `x # y`, `inline comment (\#)`},
+ }
+
+ for _, testData := range testDataList {
+ ls := NewLocaleStore()
+ err := ls.AddLocaleByIni("lang1", "Lang1", []byte("a="+testData.in), nil)
+ lang1, _ := ls.Locale("lang1")
+ require.NoError(t, err, testData.hint)
+ assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint)
+ require.NoError(t, ls.Close())
+ }
+
+ // TODO: Crowdin needs the strings to be quoted correctly and doesn't like incomplete quotes
+ // and Crowdin always outputs quoted strings if there are quotes in the strings.
+ // So, Gitea's `key="quoted" unquoted` content shouldn't be used on Crowdin directly,
+ // it should be converted to `key="\"quoted\" unquoted"` first.
+ // TODO: We can not use UnescapeValueDoubleQuotes=true, because there are a lot of back-quotes in en-US.ini,
+ // then Crowdin will output:
+ // > key = "`x \" y`"
+ // Then Gitea will read a string with back-quotes, which is incorrect.
+ // TODO: Crowdin might generate multi-line strings, quoted by double-quote, it's not supported by LocaleStore
+ // LocaleStore uses back-quote for multi-line strings, it's not supported by Crowdin.
+ // TODO: Crowdin doesn't support back-quote as string quoter, it mainly uses double-quote
+ // so, the following line will be parsed as: value="`first", comment="second`" on Crowdin
+ // > a = `first; second`
+}
diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go
new file mode 100644
index 0000000..0e6ddab
--- /dev/null
+++ b/modules/translation/i18n/localestore.go
@@ -0,0 +1,166 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package i18n
+
+import (
+ "fmt"
+ "html/template"
+ "slices"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// This file implements the static LocaleStore that will not watch for changes
+
+type locale struct {
+ store *localeStore
+ langName string
+ idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
+}
+
+var _ Locale = (*locale)(nil)
+
+type localeStore struct {
+ // After initializing has finished, these fields are read-only.
+ langNames []string
+ langDescs []string
+
+ localeMap map[string]*locale
+ trKeyToIdxMap map[string]int
+
+ defaultLang string
+}
+
+// NewLocaleStore creates a static locale store
+func NewLocaleStore() LocaleStore {
+ return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
+}
+
+// AddLocaleByIni adds locale by ini into the store
+func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error {
+ if _, ok := store.localeMap[langName]; ok {
+ return ErrLocaleAlreadyExist
+ }
+
+ store.langNames = append(store.langNames, langName)
+ store.langDescs = append(store.langDescs, langDesc)
+
+ l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)}
+ store.localeMap[l.langName] = l
+
+ iniFile, err := setting.NewConfigProviderForLocale(source, moreSource)
+ if err != nil {
+ return fmt.Errorf("unable to load ini: %w", err)
+ }
+
+ for _, section := range iniFile.Sections() {
+ for _, key := range section.Keys() {
+ var trKey string
+ // see https://codeberg.org/forgejo/discussions/issues/104
+ // https://github.com/WeblateOrg/weblate/issues/10831
+ // for an explanation of why "common" is an alternative
+ if section.Name() == "" || section.Name() == "DEFAULT" || section.Name() == "common" {
+ trKey = key.Name()
+ } else {
+ trKey = section.Name() + "." + key.Name()
+ }
+ idx, ok := store.trKeyToIdxMap[trKey]
+ if !ok {
+ idx = len(store.trKeyToIdxMap)
+ store.trKeyToIdxMap[trKey] = idx
+ }
+ l.idxToMsgMap[idx] = key.Value()
+ }
+ }
+
+ return nil
+}
+
+func (store *localeStore) HasLang(langName string) bool {
+ _, ok := store.localeMap[langName]
+ return ok
+}
+
+func (store *localeStore) ListLangNameDesc() (names, desc []string) {
+ return store.langNames, store.langDescs
+}
+
+// SetDefaultLang sets default language as a fallback
+func (store *localeStore) SetDefaultLang(lang string) {
+ store.defaultLang = lang
+}
+
+// Locale returns the locale for the lang or the default language
+func (store *localeStore) Locale(lang string) (Locale, bool) {
+ l, found := store.localeMap[lang]
+ if !found {
+ var ok bool
+ l, ok = store.localeMap[store.defaultLang]
+ if !ok {
+ // no default - return an empty locale
+ l = &locale{store: store, idxToMsgMap: make(map[int]string)}
+ }
+ }
+ return l, found
+}
+
+func (store *localeStore) Close() error {
+ return nil
+}
+
+func (l *locale) TrString(trKey string, trArgs ...any) string {
+ format := trKey
+
+ idx, ok := l.store.trKeyToIdxMap[trKey]
+ found := false
+ if ok {
+ if msg, ok := l.idxToMsgMap[idx]; ok {
+ format = msg // use the found translation
+ found = true
+ } else if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
+ // try to use default locale's translation
+ if msg, ok := def.idxToMsgMap[idx]; ok {
+ format = msg
+ found = true
+ }
+ }
+ }
+ if !found {
+ log.Error("Missing translation %q", trKey)
+ }
+
+ msg, err := Format(format, trArgs...)
+ if err != nil {
+ log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err)
+ }
+ return msg
+}
+
+func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML {
+ args := slices.Clone(trArgs)
+ for i, v := range args {
+ switch v := v.(type) {
+ case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
+ // for most basic types (including template.HTML which is safe), just do nothing and use it
+ case string:
+ args[i] = template.HTMLEscapeString(v)
+ case fmt.Stringer:
+ args[i] = template.HTMLEscapeString(v.String())
+ default:
+ args[i] = template.HTMLEscapeString(fmt.Sprint(v))
+ }
+ }
+ return template.HTML(l.TrString(trKey, args...))
+}
+
+// HasKey returns whether a key is present in this locale or not
+func (l *locale) HasKey(trKey string) bool {
+ idx, ok := l.store.trKeyToIdxMap[trKey]
+ if !ok {
+ return false
+ }
+ _, ok = l.idxToMsgMap[idx]
+ return ok
+}
diff --git a/modules/translation/mock.go b/modules/translation/mock.go
new file mode 100644
index 0000000..fe3a150
--- /dev/null
+++ b/modules/translation/mock.go
@@ -0,0 +1,40 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package translation
+
+import (
+ "fmt"
+ "html/template"
+)
+
+// MockLocale provides a mocked locale without any translations
+type MockLocale struct {
+ Lang, LangName string // these fields are used directly in templates: ctx.Locale.Lang
+}
+
+var _ Locale = (*MockLocale)(nil)
+
+func (l MockLocale) Language() string {
+ return "en"
+}
+
+func (l MockLocale) TrString(s string, _ ...any) string {
+ return s
+}
+
+func (l MockLocale) Tr(s string, a ...any) template.HTML {
+ return template.HTML(s)
+}
+
+func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
+ return template.HTML(key1)
+}
+
+func (l MockLocale) TrSize(s int64) ReadableSize {
+ return ReadableSize{fmt.Sprint(s), ""}
+}
+
+func (l MockLocale) PrettyNumber(v any) string {
+ return fmt.Sprint(v)
+}
diff --git a/modules/translation/translation.go b/modules/translation/translation.go
new file mode 100644
index 0000000..16eb55e
--- /dev/null
+++ b/modules/translation/translation.go
@@ -0,0 +1,303 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package translation
+
+import (
+ "context"
+ "html/template"
+ "sort"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/options"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation/i18n"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/dustin/go-humanize"
+ "golang.org/x/text/language"
+ "golang.org/x/text/message"
+ "golang.org/x/text/number"
+)
+
+type contextKey struct{}
+
+var ContextKey any = &contextKey{}
+
+// Locale represents an interface to translation
+type Locale interface {
+ Language() string
+ TrString(string, ...any) string
+
+ Tr(key string, args ...any) template.HTML
+ TrN(cnt any, key1, keyN string, args ...any) template.HTML
+
+ TrSize(size int64) ReadableSize
+
+ PrettyNumber(v any) string
+}
+
+// LangType represents a lang type
+type LangType struct {
+ Lang, Name string // these fields are used directly in templates: {{range .AllLangs}}{{.Lang}}{{.Name}}{{end}}
+}
+
+var (
+ lock *sync.RWMutex
+
+ allLangs []*LangType
+ allLangMap map[string]*LangType
+
+ matcher language.Matcher
+ supportedTags []language.Tag
+)
+
+// AllLangs returns all supported languages sorted by name
+func AllLangs() []*LangType {
+ return allLangs
+}
+
+// InitLocales loads the locales
+func InitLocales(ctx context.Context) {
+ if lock != nil {
+ lock.Lock()
+ defer lock.Unlock()
+ } else if !setting.IsProd && lock == nil {
+ lock = &sync.RWMutex{}
+ }
+
+ refreshLocales := func() {
+ i18n.ResetDefaultLocales()
+ localeNames, err := options.AssetFS().ListFiles("locale", true)
+ if err != nil {
+ log.Fatal("Failed to list locale files: %v", err)
+ }
+
+ localeData := make(map[string][]byte, len(localeNames))
+ for _, name := range localeNames {
+ localeData[name], err = options.Locale(name)
+ if err != nil {
+ log.Fatal("Failed to load %s locale file. %v", name, err)
+ }
+ }
+
+ supportedTags = make([]language.Tag, len(setting.Langs))
+ for i, lang := range setting.Langs {
+ supportedTags[i] = language.Raw.Make(lang)
+ }
+
+ matcher = language.NewMatcher(supportedTags)
+ for i := range setting.Names {
+ var localeDataBase []byte
+ if i == 0 && setting.Langs[0] != "en-US" {
+ // Only en-US has complete translations. When use other language as default, the en-US should still be used as fallback.
+ localeDataBase = localeData["locale_en-US.ini"]
+ if localeDataBase == nil {
+ log.Fatal("Failed to load locale_en-US.ini file.")
+ }
+ }
+
+ key := "locale_" + setting.Langs[i] + ".ini"
+ if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localeDataBase, localeData[key]); err != nil {
+ log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
+ }
+ }
+ if len(setting.Langs) != 0 {
+ defaultLangName := setting.Langs[0]
+ if defaultLangName != "en-US" {
+ log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
+ }
+ i18n.DefaultLocales.SetDefaultLang(defaultLangName)
+ }
+ }
+
+ refreshLocales()
+
+ langs, descs := i18n.DefaultLocales.ListLangNameDesc()
+ allLangs = make([]*LangType, 0, len(langs))
+ allLangMap = map[string]*LangType{}
+ for i, v := range langs {
+ l := &LangType{v, descs[i]}
+ allLangs = append(allLangs, l)
+ allLangMap[v] = l
+ }
+
+ // Sort languages case-insensitive according to their name - needed for the user settings
+ sort.Slice(allLangs, func(i, j int) bool {
+ return strings.ToLower(allLangs[i].Name) < strings.ToLower(allLangs[j].Name)
+ })
+
+ if !setting.IsProd {
+ go options.AssetFS().WatchLocalChanges(ctx, func() {
+ lock.Lock()
+ defer lock.Unlock()
+ refreshLocales()
+ })
+ }
+}
+
+// Match matches accept languages
+func Match(tags ...language.Tag) language.Tag {
+ _, i, _ := matcher.Match(tags...)
+ return supportedTags[i]
+}
+
+// locale represents the information of localization.
+type locale struct {
+ i18n.Locale
+ Lang, LangName string // these fields are used directly in templates: ctx.Locale.Lang
+ msgPrinter *message.Printer
+}
+
+var _ Locale = (*locale)(nil)
+
+// NewLocale return a locale
+func NewLocale(lang string) Locale {
+ if lock != nil {
+ lock.RLock()
+ defer lock.RUnlock()
+ }
+
+ langName := "unknown"
+ if l, ok := allLangMap[lang]; ok {
+ langName = l.Name
+ } else if len(setting.Langs) > 0 {
+ lang = setting.Langs[0]
+ langName = setting.Names[0]
+ }
+
+ i18nLocale, _ := i18n.GetLocale(lang)
+ l := &locale{
+ Locale: i18nLocale,
+ Lang: lang,
+ LangName: langName,
+ }
+ if langTag, err := language.Parse(lang); err != nil {
+ log.Error("Failed to parse language tag from name %q: %v", l.Lang, err)
+ l.msgPrinter = message.NewPrinter(language.English)
+ } else {
+ l.msgPrinter = message.NewPrinter(langTag)
+ }
+ return l
+}
+
+func (l *locale) Language() string {
+ return l.Lang
+}
+
+// Language specific rules for translating plural texts
+var trNLangRules = map[string]func(int64) int{
+ // the default rule is "en-US" if a language isn't listed here
+ "en-US": func(cnt int64) int {
+ if cnt == 1 {
+ return 0
+ }
+ return 1
+ },
+ "lv-LV": func(cnt int64) int {
+ if cnt%10 == 1 && cnt%100 != 11 {
+ return 0
+ }
+ return 1
+ },
+ "ru-RU": func(cnt int64) int {
+ if cnt%10 == 1 && cnt%100 != 11 {
+ return 0
+ }
+ return 1
+ },
+ "zh-CN": func(cnt int64) int {
+ return 0
+ },
+ "zh-HK": func(cnt int64) int {
+ return 0
+ },
+ "zh-TW": func(cnt int64) int {
+ return 0
+ },
+ "fr-FR": func(cnt int64) int {
+ if cnt > -2 && cnt < 2 {
+ return 0
+ }
+ return 1
+ },
+}
+
+func (l *locale) Tr(s string, args ...any) template.HTML {
+ return l.TrHTML(s, args...)
+}
+
+// TrN returns translated message for plural text translation
+func (l *locale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
+ var c int64
+ if t, ok := cnt.(int); ok {
+ c = int64(t)
+ } else if t, ok := cnt.(int16); ok {
+ c = int64(t)
+ } else if t, ok := cnt.(int32); ok {
+ c = int64(t)
+ } else if t, ok := cnt.(int64); ok {
+ c = t
+ } else {
+ return l.Tr(keyN, args...)
+ }
+
+ ruleFunc, ok := trNLangRules[l.Lang]
+ if !ok {
+ ruleFunc = trNLangRules["en-US"]
+ }
+
+ if ruleFunc(c) == 0 {
+ return l.Tr(key1, args...)
+ }
+ return l.Tr(keyN, args...)
+}
+
+type ReadableSize struct {
+ PrettyNumber string
+ TranslatedUnit string
+}
+
+func (bs ReadableSize) String() string {
+ return bs.PrettyNumber + " " + bs.TranslatedUnit
+}
+
+// TrSize returns array containing pretty formatted size and localized output of FileSize
+// output of humanize.IBytes has to be split in order to be localized
+func (l *locale) TrSize(s int64) ReadableSize {
+ us := uint64(s)
+ if s < 0 {
+ us = uint64(-s)
+ }
+ untranslated := humanize.IBytes(us)
+ if s < 0 {
+ untranslated = "-" + untranslated
+ }
+ numberVal, unitVal, found := strings.Cut(untranslated, " ")
+ if !found {
+ log.Error("no space in go-humanized size of %d: %q", s, untranslated)
+ }
+ numberVal = l.PrettyNumber(numberVal)
+ unitVal = l.TrString("munits.data." + strings.ToLower(unitVal))
+ return ReadableSize{numberVal, unitVal}
+}
+
+func (l *locale) PrettyNumber(v any) string {
+ // TODO: this mechanism is not good enough, the complete solution is to switch the translation system to ICU message format
+ if s, ok := v.(string); ok {
+ if num, err := util.ToInt64(s); err == nil {
+ v = num
+ } else if num, err := util.ToFloat64(s); err == nil {
+ v = num
+ }
+ }
+ return l.msgPrinter.Sprintf("%v", number.Decimal(v))
+}
+
+func init() {
+ // prepare a default matcher, especially for tests
+ supportedTags = []language.Tag{language.English}
+ matcher = language.NewMatcher(supportedTags)
+}
diff --git a/modules/translation/translation_test.go b/modules/translation/translation_test.go
new file mode 100644
index 0000000..bffbb15
--- /dev/null
+++ b/modules/translation/translation_test.go
@@ -0,0 +1,50 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package translation
+
+// TODO: make this package friendly to testing
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/translation/i18n"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTrSize(t *testing.T) {
+ l := NewLocale("")
+ size := int64(1)
+ assert.EqualValues(t, "1 munits.data.b", l.TrSize(size).String())
+ size *= 2048
+ assert.EqualValues(t, "2 munits.data.kib", l.TrSize(size).String())
+ size *= 2048
+ assert.EqualValues(t, "4 munits.data.mib", l.TrSize(size).String())
+ size *= 2048
+ assert.EqualValues(t, "8 munits.data.gib", l.TrSize(size).String())
+ size *= 2048
+ assert.EqualValues(t, "16 munits.data.tib", l.TrSize(size).String())
+ size *= 2048
+ assert.EqualValues(t, "32 munits.data.pib", l.TrSize(size).String())
+ size *= 128
+ assert.EqualValues(t, "4 munits.data.eib", l.TrSize(size).String())
+}
+
+func TestPrettyNumber(t *testing.T) {
+ i18n.ResetDefaultLocales()
+
+ allLangMap = make(map[string]*LangType)
+ allLangMap["id-ID"] = &LangType{Lang: "id-ID", Name: "Bahasa Indonesia"}
+
+ l := NewLocale("id-ID")
+ assert.EqualValues(t, "1.000.000", l.PrettyNumber(1000000))
+ assert.EqualValues(t, "1.000.000,1", l.PrettyNumber(1000000.1))
+ assert.EqualValues(t, "1.000.000", l.PrettyNumber("1000000"))
+ assert.EqualValues(t, "1.000.000", l.PrettyNumber("1000000.0"))
+ assert.EqualValues(t, "1.000.000,1", l.PrettyNumber("1000000.1"))
+
+ l = NewLocale("nosuch")
+ assert.EqualValues(t, "1,000,000", l.PrettyNumber(1000000))
+ assert.EqualValues(t, "1,000,000.1", l.PrettyNumber(1000000.1))
+}
diff --git a/modules/turnstile/turnstile.go b/modules/turnstile/turnstile.go
new file mode 100644
index 0000000..38d0233
--- /dev/null
+++ b/modules/turnstile/turnstile.go
@@ -0,0 +1,92 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package turnstile
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Response is the structure of JSON returned from API
+type Response struct {
+ Success bool `json:"success"`
+ ChallengeTS string `json:"challenge_ts"`
+ Hostname string `json:"hostname"`
+ ErrorCodes []ErrorCode `json:"error-codes"`
+ Action string `json:"login"`
+ Cdata string `json:"cdata"`
+}
+
+// Verify calls Cloudflare Turnstile API to verify token
+func Verify(ctx context.Context, response string) (bool, error) {
+ // Cloudflare turnstile official access instruction address: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
+ post := url.Values{
+ "secret": {setting.Service.CfTurnstileSecret},
+ "response": {response},
+ }
+ // Basically a copy of http.PostForm, but with a context
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(post.Encode()))
+ if err != nil {
+ return false, fmt.Errorf("Failed to create CAPTCHA request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, fmt.Errorf("Failed to read CAPTCHA response: %w", err)
+ }
+
+ var jsonResponse Response
+ if err := json.Unmarshal(body, &jsonResponse); err != nil {
+ return false, fmt.Errorf("Failed to parse CAPTCHA response: %w", err)
+ }
+
+ var respErr error
+ if len(jsonResponse.ErrorCodes) > 0 {
+ respErr = jsonResponse.ErrorCodes[0]
+ }
+ return jsonResponse.Success, respErr
+}
+
+// ErrorCode is a reCaptcha error
+type ErrorCode string
+
+// String fulfills the Stringer interface
+func (e ErrorCode) String() string {
+ switch e {
+ case "missing-input-secret":
+ return "The secret parameter was not passed."
+ case "invalid-input-secret":
+ return "The secret parameter was invalid or did not exist."
+ case "missing-input-response":
+ return "The response parameter was not passed."
+ case "invalid-input-response":
+ return "The response parameter is invalid or has expired."
+ case "bad-request":
+ return "The request was rejected because it was malformed."
+ case "timeout-or-duplicate":
+ return "The response parameter has already been validated before."
+ case "internal-error":
+ return "An internal error happened while validating the response. The request can be retried."
+ }
+ return string(e)
+}
+
+// Error fulfills the error interface
+func (e ErrorCode) Error() string {
+ return e.String()
+}
diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go
new file mode 100644
index 0000000..6aec5c2
--- /dev/null
+++ b/modules/typesniffer/typesniffer.go
@@ -0,0 +1,143 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package typesniffer
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Use at most this many bytes to determine Content Type.
+const sniffLen = 1024
+
+const (
+ // SvgMimeType MIME type of SVG images.
+ SvgMimeType = "image/svg+xml"
+ // ApplicationOctetStream MIME type of binary files.
+ ApplicationOctetStream = "application/octet-stream"
+)
+
+var (
+ svgComment = regexp.MustCompile(`(?s)<!--.*?-->`)
+ svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
+ svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
+)
+
+// SniffedType contains information about a blobs type.
+type SniffedType struct {
+ contentType string
+}
+
+// IsText etects if content format is plain text.
+func (ct SniffedType) IsText() bool {
+ return strings.Contains(ct.contentType, "text/")
+}
+
+// IsImage detects if data is an image format
+func (ct SniffedType) IsImage() bool {
+ return strings.Contains(ct.contentType, "image/")
+}
+
+// IsSvgImage detects if data is an SVG image format
+func (ct SniffedType) IsSvgImage() bool {
+ return strings.Contains(ct.contentType, SvgMimeType)
+}
+
+// IsPDF detects if data is a PDF format
+func (ct SniffedType) IsPDF() bool {
+ return strings.Contains(ct.contentType, "application/pdf")
+}
+
+// IsVideo detects if data is an video format
+func (ct SniffedType) IsVideo() bool {
+ return strings.Contains(ct.contentType, "video/")
+}
+
+// IsAudio detects if data is an video format
+func (ct SniffedType) IsAudio() bool {
+ return strings.Contains(ct.contentType, "audio/")
+}
+
+// IsRepresentableAsText returns true if file content can be represented as
+// plain text or is empty.
+func (ct SniffedType) IsRepresentableAsText() bool {
+ return ct.IsText() || ct.IsSvgImage()
+}
+
+// IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser
+func (ct SniffedType) IsBrowsableBinaryType() bool {
+ return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio()
+}
+
+// GetMimeType returns the mime type
+func (ct SniffedType) GetMimeType() string {
+ return strings.SplitN(ct.contentType, ";", 2)[0]
+}
+
+// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty.
+func DetectContentType(data []byte) SniffedType {
+ if len(data) == 0 {
+ return SniffedType{"text/unknown"}
+ }
+
+ ct := http.DetectContentType(data)
+
+ if len(data) > sniffLen {
+ data = data[:sniffLen]
+ }
+
+ // SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888
+
+ detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")
+ detectByXML := strings.Contains(ct, "text/xml")
+ if detectByHTML || detectByXML {
+ dataProcessed := svgComment.ReplaceAll(data, nil)
+ dataProcessed = bytes.TrimSpace(dataProcessed)
+ if detectByHTML && svgTagRegex.Match(dataProcessed) ||
+ detectByXML && svgTagInXMLRegex.Match(dataProcessed) {
+ ct = SvgMimeType
+ }
+ }
+
+ if strings.HasPrefix(ct, "audio/") && bytes.HasPrefix(data, []byte("ID3")) {
+ // The MP3 detection is quite inaccurate, any content with "ID3" prefix will result in "audio/mpeg".
+ // So remove the "ID3" prefix and detect again, if result is text, then it must be text content.
+ // This works especially because audio files contain many unprintable/invalid characters like `0x00`
+ ct2 := http.DetectContentType(data[3:])
+ if strings.HasPrefix(ct2, "text/") {
+ ct = ct2
+ }
+ }
+
+ if ct == "application/ogg" {
+ dataHead := data
+ if len(dataHead) > 256 {
+ dataHead = dataHead[:256] // only need to do a quick check for the file header
+ }
+ if bytes.Contains(dataHead, []byte("theora")) || bytes.Contains(dataHead, []byte("dirac")) {
+ ct = "video/ogg" // ogg is only used for some video formats, and it's not popular
+ } else {
+ ct = "audio/ogg" // for most cases, it is used as an audio container
+ }
+ }
+ return SniffedType{ct}
+}
+
+// DetectContentTypeFromReader guesses the content type contained in the reader.
+func DetectContentTypeFromReader(r io.Reader) (SniffedType, error) {
+ buf := make([]byte, sniffLen)
+ n, err := util.ReadAtMost(r, buf)
+ if err != nil {
+ return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err)
+ }
+ buf = buf[:n]
+
+ return DetectContentType(buf), nil
+}
diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go
new file mode 100644
index 0000000..f6fa07e
--- /dev/null
+++ b/modules/typesniffer/typesniffer_test.go
@@ -0,0 +1,137 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package typesniffer
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/hex"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDetectContentTypeLongerThanSniffLen(t *testing.T) {
+ // Pre-condition: Shorter than sniffLen detects SVG.
+ assert.Equal(t, "image/svg+xml", DetectContentType([]byte(`<!-- Comment --><svg></svg>`)).contentType)
+ // Longer than sniffLen detects something else.
+ assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(`<!-- `+strings.Repeat("x", sniffLen)+` --><svg></svg>`)).contentType)
+}
+
+func TestIsTextFile(t *testing.T) {
+ assert.True(t, DetectContentType([]byte{}).IsText())
+ assert.True(t, DetectContentType([]byte("lorem ipsum")).IsText())
+}
+
+func TestIsSvgImage(t *testing.T) {
+ assert.True(t, DetectContentType([]byte("<svg></svg>")).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(" <svg></svg>")).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<svg width="100"></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?><svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<!-- Comment -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<!-- Multiple -->
+ <!-- Comments -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<!-- Multiline
+ Comment -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd">
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?>
+ <!-- Comment -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?>
+ <!-- Multiple -->
+ <!-- Comments -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?>
+ <!-- Multiline
+ Comment -->
+ <svg></svg>`)).IsSvgImage())
+ assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?>
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+ <!-- Multiline
+ Comment -->
+ <svg></svg>`)).IsSvgImage())
+
+ // the DetectContentType should work for incomplete data, because only beginning bytes are used for detection
+ assert.True(t, DetectContentType([]byte(`<svg>....`)).IsSvgImage())
+
+ assert.False(t, DetectContentType([]byte{}).IsSvgImage())
+ assert.False(t, DetectContentType([]byte("svg")).IsSvgImage())
+ assert.False(t, DetectContentType([]byte("<svgfoo></svgfoo>")).IsSvgImage())
+ assert.False(t, DetectContentType([]byte("text<svg></svg>")).IsSvgImage())
+ assert.False(t, DetectContentType([]byte("<html><body><svg></svg></body></html>")).IsSvgImage())
+ assert.False(t, DetectContentType([]byte(`<script>"<svg></svg>"</script>`)).IsSvgImage())
+ assert.False(t, DetectContentType([]byte(`<!-- <svg></svg> inside comment -->
+ <foo></foo>`)).IsSvgImage())
+ assert.False(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?>
+ <!-- <svg></svg> inside comment -->
+ <foo></foo>`)).IsSvgImage())
+
+ assert.False(t, DetectContentType([]byte(`
+<!-- comment1 -->
+<div>
+ <!-- comment2 -->
+ <svg></svg>
+</div>
+`)).IsSvgImage())
+
+ assert.False(t, DetectContentType([]byte(`
+<!-- comment1
+-->
+<div>
+ <!-- comment2
+-->
+ <svg></svg>
+</div>
+`)).IsSvgImage())
+ assert.False(t, DetectContentType([]byte(`<html><body><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg></body></html>`)).IsSvgImage())
+ assert.False(t, DetectContentType([]byte(`<html><body><?xml version="1.0" encoding="UTF-8"?><svg></svg></body></html>`)).IsSvgImage())
+}
+
+func TestIsPDF(t *testing.T) {
+ pdf, _ := base64.StdEncoding.DecodeString("JVBERi0xLjYKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nF3NPwsCMQwF8D2f4s2CNYk1baF0EHRwOwg4iJt/NsFb/PpevUE4Mjwe")
+ assert.True(t, DetectContentType(pdf).IsPDF())
+ assert.False(t, DetectContentType([]byte("plain text")).IsPDF())
+}
+
+func TestIsVideo(t *testing.T) {
+ mp4, _ := base64.StdEncoding.DecodeString("AAAAGGZ0eXBtcDQyAAAAAGlzb21tcDQyAAEI721vb3YAAABsbXZoZAAAAADaBlwX2gZcFwAAA+gA")
+ assert.True(t, DetectContentType(mp4).IsVideo())
+ assert.False(t, DetectContentType([]byte("plain text")).IsVideo())
+}
+
+func TestIsAudio(t *testing.T) {
+ mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
+ assert.True(t, DetectContentType(mp3).IsAudio())
+ assert.False(t, DetectContentType([]byte("plain text")).IsAudio())
+
+ assert.True(t, DetectContentType([]byte("ID3Toy\000")).IsAudio())
+ assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ...")).IsText()) // test ID3 tag for plain text
+ assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char
+}
+
+func TestDetectContentTypeFromReader(t *testing.T) {
+ mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
+ st, err := DetectContentTypeFromReader(bytes.NewReader(mp3))
+ require.NoError(t, err)
+ assert.True(t, st.IsAudio())
+}
+
+func TestDetectContentTypeOgg(t *testing.T) {
+ oggAudio, _ := hex.DecodeString("4f67675300020000000000000000352f0000000000007dc39163011e01766f72626973000000000244ac0000000000000071020000000000b8014f6767530000")
+ st, err := DetectContentTypeFromReader(bytes.NewReader(oggAudio))
+ require.NoError(t, err)
+ assert.True(t, st.IsAudio())
+
+ oggVideo, _ := hex.DecodeString("4f676753000200000000000000007d9747ef000000009b59daf3012a807468656f7261030201001e00110001e000010e00020000001e00000001000001000001")
+ st, err = DetectContentTypeFromReader(bytes.NewReader(oggVideo))
+ require.NoError(t, err)
+ assert.True(t, st.IsVideo())
+}
diff --git a/modules/updatechecker/update_checker.go b/modules/updatechecker/update_checker.go
new file mode 100644
index 0000000..0c93f08
--- /dev/null
+++ b/modules/updatechecker/update_checker.go
@@ -0,0 +1,143 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package updatechecker
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/system"
+
+ "github.com/hashicorp/go-version"
+)
+
+// CheckerState stores the remote version from the JSON endpoint
+type CheckerState struct {
+ LatestVersion string
+}
+
+// Name returns the name of the state item for update checker
+func (r *CheckerState) Name() string {
+ return "update-checker"
+}
+
+// GiteaUpdateChecker returns error when new version of Gitea is available
+func GiteaUpdateChecker(httpEndpoint, domainEndpoint string) error {
+ var version string
+ var err error
+ if domainEndpoint != "" {
+ version, err = getVersionDNS(domainEndpoint)
+ } else {
+ version, err = getVersionHTTP(httpEndpoint)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ return UpdateRemoteVersion(context.Background(), version)
+}
+
+// getVersionDNS will request the TXT records for the domain. If a record starts
+// with "forgejo_versions=" everything after that will be used as the latest
+// version available.
+func getVersionDNS(domainEndpoint string) (version string, err error) {
+ records, err := net.LookupTXT(domainEndpoint)
+ if err != nil {
+ return "", err
+ }
+
+ if len(records) == 0 {
+ return "", errors.New("no TXT records were found")
+ }
+
+ for _, record := range records {
+ if strings.HasPrefix(record, "forgejo_versions=") {
+ // Get all supported versions, separated by a comma.
+ supportedVersions := strings.Split(strings.TrimPrefix(record, "forgejo_versions="), ",")
+ // For now always return the latest supported version.
+ return supportedVersions[len(supportedVersions)-1], nil
+ }
+ }
+
+ return "", errors.New("there is no TXT record with a valid value")
+}
+
+// getVersionHTTP will make an HTTP request to the endpoint, and the returned
+// content is JSON. The "latest.version" path's value will be used as the latest
+// version available.
+func getVersionHTTP(httpEndpoint string) (version string, err error) {
+ httpClient := &http.Client{
+ Transport: &http.Transport{
+ Proxy: proxy.Proxy(),
+ },
+ }
+
+ req, err := http.NewRequest("GET", httpEndpoint, nil)
+ if err != nil {
+ return "", err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ type respType struct {
+ Latest struct {
+ Version string `json:"version"`
+ } `json:"latest"`
+ }
+ respData := respType{}
+ err = json.Unmarshal(body, &respData)
+ if err != nil {
+ return "", err
+ }
+ return respData.Latest.Version, nil
+}
+
+// UpdateRemoteVersion updates the latest available version of Gitea
+func UpdateRemoteVersion(ctx context.Context, version string) (err error) {
+ return system.AppState.Set(ctx, &CheckerState{LatestVersion: version})
+}
+
+// GetRemoteVersion returns the current remote version (or currently installed version if fail to fetch from DB)
+func GetRemoteVersion(ctx context.Context) string {
+ item := new(CheckerState)
+ if err := system.AppState.Get(ctx, item); err != nil {
+ return ""
+ }
+ return item.LatestVersion
+}
+
+// GetNeedUpdate returns true whether a newer version of Gitea is available
+func GetNeedUpdate(ctx context.Context) bool {
+ curVer, err := version.NewVersion(setting.AppVer)
+ if err != nil {
+ // return false to fail silently
+ return false
+ }
+ remoteVerStr := GetRemoteVersion(ctx)
+ if remoteVerStr == "" {
+ // no remote version is known
+ return false
+ }
+ remoteVer, err := version.NewVersion(remoteVerStr)
+ if err != nil {
+ // return false to fail silently
+ return false
+ }
+ return curVer.LessThan(remoteVer)
+}
diff --git a/modules/updatechecker/update_checker_test.go b/modules/updatechecker/update_checker_test.go
new file mode 100644
index 0000000..5ac2603
--- /dev/null
+++ b/modules/updatechecker/update_checker_test.go
@@ -0,0 +1,17 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package updatechecker
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDNSUpdate(t *testing.T) {
+ version, err := getVersionDNS("release.forgejo.org")
+ require.NoError(t, err)
+ assert.NotEmpty(t, version)
+}
diff --git a/modules/uri/uri.go b/modules/uri/uri.go
new file mode 100644
index 0000000..768bc66
--- /dev/null
+++ b/modules/uri/uri.go
@@ -0,0 +1,42 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package uri
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+)
+
+// ErrURISchemeNotSupported represents a scheme error
+type ErrURISchemeNotSupported struct {
+ Scheme string
+}
+
+func (e ErrURISchemeNotSupported) Error() string {
+ return fmt.Sprintf("Unsupported scheme: %v", e.Scheme)
+}
+
+// Open open a local file or a remote file
+func Open(uriStr string) (io.ReadCloser, error) {
+ u, err := url.Parse(uriStr)
+ if err != nil {
+ return nil, err
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http", "https":
+ f, err := http.Get(uriStr)
+ if err != nil {
+ return nil, err
+ }
+ return f.Body, nil
+ case "file":
+ return os.Open(u.Path)
+ default:
+ return nil, ErrURISchemeNotSupported{Scheme: u.Scheme}
+ }
+}
diff --git a/modules/uri/uri_test.go b/modules/uri/uri_test.go
new file mode 100644
index 0000000..71a8985
--- /dev/null
+++ b/modules/uri/uri_test.go
@@ -0,0 +1,19 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package uri
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadURI(t *testing.T) {
+ p, err := filepath.Abs("./uri.go")
+ require.NoError(t, err)
+ f, err := Open("file://" + p)
+ require.NoError(t, err)
+ defer f.Close()
+}
diff --git a/modules/user/user.go b/modules/user/user.go
new file mode 100644
index 0000000..eee401a
--- /dev/null
+++ b/modules/user/user.go
@@ -0,0 +1,35 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "os"
+ "os/user"
+ "runtime"
+ "strings"
+)
+
+// CurrentUsername return current login OS user name
+func CurrentUsername() string {
+ userinfo, err := user.Current()
+ if err != nil {
+ return fallbackCurrentUsername()
+ }
+ username := userinfo.Username
+ if runtime.GOOS == "windows" {
+ parts := strings.Split(username, "\\")
+ username = parts[len(parts)-1]
+ }
+ return username
+}
+
+// Old method, used if new method doesn't work on your OS for some reason
+func fallbackCurrentUsername() string {
+ curUserName := os.Getenv("USER")
+ if len(curUserName) > 0 {
+ return curUserName
+ }
+
+ return os.Getenv("USERNAME")
+}
diff --git a/modules/user/user_test.go b/modules/user/user_test.go
new file mode 100644
index 0000000..372a675
--- /dev/null
+++ b/modules/user/user_test.go
@@ -0,0 +1,43 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "os/exec"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func getWhoamiOutput() (string, error) {
+ output, err := exec.Command("whoami").Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(output)), nil
+}
+
+func TestCurrentUsername(t *testing.T) {
+ user := CurrentUsername()
+ if len(user) == 0 {
+ t.Errorf("expected non-empty user, got: %s", user)
+ }
+ // Windows whoami is weird, so just skip remaining tests
+ if runtime.GOOS == "windows" {
+ t.Skip("skipped test because of weird whoami on Windows")
+ }
+ whoami, err := getWhoamiOutput()
+ if err != nil {
+ t.Errorf("failed to run whoami to test current user: %f", err)
+ }
+ user = CurrentUsername()
+ if user != whoami {
+ t.Errorf("expected %s as user, got: %s", whoami, user)
+ }
+ t.Setenv("USER", "spoofed")
+ user = CurrentUsername()
+ if user != whoami {
+ t.Errorf("expected %s as user, got: %s", whoami, user)
+ }
+}
diff --git a/modules/util/color.go b/modules/util/color.go
new file mode 100644
index 0000000..9c520dc
--- /dev/null
+++ b/modules/util/color.go
@@ -0,0 +1,57 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package util
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// Get color as RGB values in 0..255 range from the hex color string (with or without #)
+func HexToRBGColor(colorString string) (float64, float64, float64) {
+ hexString := colorString
+ if strings.HasPrefix(colorString, "#") {
+ hexString = colorString[1:]
+ }
+ // only support transfer of rgb, rgba, rrggbb and rrggbbaa
+ // if not in these formats, use default values 0, 0, 0
+ if len(hexString) != 3 && len(hexString) != 4 && len(hexString) != 6 && len(hexString) != 8 {
+ return 0, 0, 0
+ }
+ if len(hexString) == 3 || len(hexString) == 4 {
+ hexString = fmt.Sprintf("%c%c%c%c%c%c", hexString[0], hexString[0], hexString[1], hexString[1], hexString[2], hexString[2])
+ }
+ if len(hexString) == 8 {
+ hexString = hexString[0:6]
+ }
+ color, err := strconv.ParseUint(hexString, 16, 64)
+ if err != nil {
+ return 0, 0, 0
+ }
+ r := float64(uint8(0xFF & (uint32(color) >> 16)))
+ g := float64(uint8(0xFF & (uint32(color) >> 8)))
+ b := float64(uint8(0xFF & uint32(color)))
+ return r, g, b
+}
+
+// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
+// Keep this in sync with web_src/js/utils/color.js
+func GetRelativeLuminance(color string) float64 {
+ r, g, b := HexToRBGColor(color)
+ return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255
+}
+
+func UseLightText(backgroundColor string) bool {
+ return GetRelativeLuminance(backgroundColor) < 0.453
+}
+
+// Given a background color, returns a black or white foreground color that the highest
+// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
+// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
+func ContrastColor(backgroundColor string) string {
+ if UseLightText(backgroundColor) {
+ return "#fff"
+ }
+ return "#000"
+}
diff --git a/modules/util/color_test.go b/modules/util/color_test.go
new file mode 100644
index 0000000..abd5551
--- /dev/null
+++ b/modules/util/color_test.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_HexToRBGColor(t *testing.T) {
+ cases := []struct {
+ colorString string
+ expectedR float64
+ expectedG float64
+ expectedB float64
+ }{
+ {"2b8685", 43, 134, 133},
+ {"1e1", 17, 238, 17},
+ {"#1e1", 17, 238, 17},
+ {"1e16", 17, 238, 17},
+ {"3bb6b3", 59, 182, 179},
+ {"#3bb6b399", 59, 182, 179},
+ {"#0", 0, 0, 0},
+ {"#00000", 0, 0, 0},
+ {"#1234567", 0, 0, 0},
+ }
+ for n, c := range cases {
+ r, g, b := HexToRBGColor(c.colorString)
+ assert.InDelta(t, c.expectedR, r, 0, "case %d: error R should match: expected %f, but get %f", n, c.expectedR, r)
+ assert.InDelta(t, c.expectedG, g, 0, "case %d: error G should match: expected %f, but get %f", n, c.expectedG, g)
+ assert.InDelta(t, c.expectedB, b, 0, "case %d: error B should match: expected %f, but get %f", n, c.expectedB, b)
+ }
+}
+
+func Test_UseLightText(t *testing.T) {
+ cases := []struct {
+ color string
+ expected string
+ }{
+ {"#d73a4a", "#fff"},
+ {"#0075ca", "#fff"},
+ {"#cfd3d7", "#000"},
+ {"#a2eeef", "#000"},
+ {"#7057ff", "#fff"},
+ {"#008672", "#fff"},
+ {"#e4e669", "#000"},
+ {"#d876e3", "#000"},
+ {"#ffffff", "#000"},
+ {"#2b8684", "#fff"},
+ {"#2b8786", "#fff"},
+ {"#2c8786", "#000"},
+ {"#3bb6b3", "#000"},
+ {"#7c7268", "#fff"},
+ {"#7e716c", "#fff"},
+ {"#81706d", "#fff"},
+ {"#807070", "#fff"},
+ {"#84b6eb", "#000"},
+ }
+ for n, c := range cases {
+ assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n)
+ }
+}
diff --git a/modules/util/error.go b/modules/util/error.go
new file mode 100644
index 0000000..0f35971
--- /dev/null
+++ b/modules/util/error.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Common Errors forming the base of our error system
+//
+// Many Errors returned by Gitea can be tested against these errors
+// using errors.Is.
+var (
+ ErrInvalidArgument = errors.New("invalid argument")
+ ErrPermissionDenied = errors.New("permission denied")
+ ErrAlreadyExist = errors.New("resource already exists")
+ ErrNotExist = errors.New("resource does not exist")
+)
+
+// SilentWrap provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message
+// Especially useful for "untyped" errors created with "errors.New(…)" that can be classified as 'invalid argument', 'permission denied', 'exists already', or 'does not exist'
+type SilentWrap struct {
+ Message string
+ Err error
+}
+
+// Error returns the message
+func (w SilentWrap) Error() string {
+ return w.Message
+}
+
+// Unwrap returns the underlying error
+func (w SilentWrap) Unwrap() error {
+ return w.Err
+}
+
+// NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error
+func NewSilentWrapErrorf(unwrap error, message string, args ...any) error {
+ if len(args) == 0 {
+ return SilentWrap{Message: message, Err: unwrap}
+ }
+ return SilentWrap{Message: fmt.Sprintf(message, args...), Err: unwrap}
+}
+
+// NewInvalidArgumentErrorf returns an error that formats as the given text but unwraps as an ErrInvalidArgument
+func NewInvalidArgumentErrorf(message string, args ...any) error {
+ return NewSilentWrapErrorf(ErrInvalidArgument, message, args...)
+}
+
+// NewPermissionDeniedErrorf returns an error that formats as the given text but unwraps as an ErrPermissionDenied
+func NewPermissionDeniedErrorf(message string, args ...any) error {
+ return NewSilentWrapErrorf(ErrPermissionDenied, message, args...)
+}
+
+// NewAlreadyExistErrorf returns an error that formats as the given text but unwraps as an ErrAlreadyExist
+func NewAlreadyExistErrorf(message string, args ...any) error {
+ return NewSilentWrapErrorf(ErrAlreadyExist, message, args...)
+}
+
+// NewNotExistErrorf returns an error that formats as the given text but unwraps as an ErrNotExist
+func NewNotExistErrorf(message string, args ...any) error {
+ return NewSilentWrapErrorf(ErrNotExist, message, args...)
+}
diff --git a/modules/util/file_unix.go b/modules/util/file_unix.go
new file mode 100644
index 0000000..79a29c8
--- /dev/null
+++ b/modules/util/file_unix.go
@@ -0,0 +1,27 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package util
+
+import (
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+var defaultUmask int
+
+func init() {
+ // at the moment, the umask could only be gotten by calling unix.Umask(newUmask)
+ // use 0o077 as temp new umask to reduce the risks if this umask is used anywhere else before the correct umask is recovered
+ tempUmask := 0o077
+ defaultUmask = unix.Umask(tempUmask)
+ unix.Umask(defaultUmask)
+}
+
+func ApplyUmask(f string, newMode os.FileMode) error {
+ mod := newMode & ^os.FileMode(defaultUmask)
+ return os.Chmod(f, mod)
+}
diff --git a/modules/util/file_unix_test.go b/modules/util/file_unix_test.go
new file mode 100644
index 0000000..d60082a
--- /dev/null
+++ b/modules/util/file_unix_test.go
@@ -0,0 +1,36 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package util
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyUmask(t *testing.T) {
+ f, err := os.CreateTemp(t.TempDir(), "test-filemode-")
+ require.NoError(t, err)
+
+ err = os.Chmod(f.Name(), 0o777)
+ require.NoError(t, err)
+ st, err := os.Stat(f.Name())
+ require.NoError(t, err)
+ assert.EqualValues(t, 0o777, st.Mode().Perm()&0o777)
+
+ oldDefaultUmask := defaultUmask
+ defaultUmask = 0o037
+ defer func() {
+ defaultUmask = oldDefaultUmask
+ }()
+ err = ApplyUmask(f.Name(), os.ModePerm)
+ require.NoError(t, err)
+ st, err = os.Stat(f.Name())
+ require.NoError(t, err)
+ assert.EqualValues(t, 0o740, st.Mode().Perm()&0o777)
+}
diff --git a/modules/util/file_windows.go b/modules/util/file_windows.go
new file mode 100644
index 0000000..77a33d3
--- /dev/null
+++ b/modules/util/file_windows.go
@@ -0,0 +1,15 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package util
+
+import (
+ "os"
+)
+
+func ApplyUmask(f string, newMode os.FileMode) error {
+ // do nothing for Windows, because Windows doesn't use umask
+ return nil
+}
diff --git a/modules/util/filebuffer/file_backed_buffer.go b/modules/util/filebuffer/file_backed_buffer.go
new file mode 100644
index 0000000..739543e
--- /dev/null
+++ b/modules/util/filebuffer/file_backed_buffer.go
@@ -0,0 +1,156 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package filebuffer
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "math"
+ "os"
+)
+
+var (
+ // ErrInvalidMemorySize occurs if the memory size is not in a valid range
+ ErrInvalidMemorySize = errors.New("Memory size must be greater 0 and lower math.MaxInt32")
+ // ErrWriteAfterRead occurs if Write is called after a read operation
+ ErrWriteAfterRead = errors.New("Write is unsupported after a read operation")
+)
+
+type readAtSeeker interface {
+ io.ReadSeeker
+ io.ReaderAt
+}
+
+// FileBackedBuffer uses a memory buffer with a fixed size.
+// If more data is written a temporary file is used instead.
+// It implements io.ReadWriteCloser, io.ReadSeekCloser and io.ReaderAt
+type FileBackedBuffer struct {
+ maxMemorySize int64
+ size int64
+ buffer bytes.Buffer
+ file *os.File
+ reader readAtSeeker
+}
+
+// New creates a file backed buffer with a specific maximum memory size
+func New(maxMemorySize int) (*FileBackedBuffer, error) {
+ if maxMemorySize < 0 || maxMemorySize > math.MaxInt32 {
+ return nil, ErrInvalidMemorySize
+ }
+
+ return &FileBackedBuffer{
+ maxMemorySize: int64(maxMemorySize),
+ }, nil
+}
+
+// CreateFromReader creates a file backed buffer and copies the provided reader data into it.
+func CreateFromReader(r io.Reader, maxMemorySize int) (*FileBackedBuffer, error) {
+ b, err := New(maxMemorySize)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = io.Copy(b, r)
+ if err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+// Write implements io.Writer
+func (b *FileBackedBuffer) Write(p []byte) (int, error) {
+ if b.reader != nil {
+ return 0, ErrWriteAfterRead
+ }
+
+ var n int
+ var err error
+
+ if b.file != nil {
+ n, err = b.file.Write(p)
+ } else {
+ if b.size+int64(len(p)) > b.maxMemorySize {
+ b.file, err = os.CreateTemp("", "gitea-buffer-")
+ if err != nil {
+ return 0, err
+ }
+
+ _, err = io.Copy(b.file, &b.buffer)
+ if err != nil {
+ return 0, err
+ }
+
+ return b.Write(p)
+ }
+
+ n, err = b.buffer.Write(p)
+ }
+
+ if err != nil {
+ return n, err
+ }
+ b.size += int64(n)
+ return n, nil
+}
+
+// Size returns the byte size of the buffered data
+func (b *FileBackedBuffer) Size() int64 {
+ return b.size
+}
+
+func (b *FileBackedBuffer) switchToReader() error {
+ if b.reader != nil {
+ return nil
+ }
+
+ if b.file != nil {
+ if _, err := b.file.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ b.reader = b.file
+ } else {
+ b.reader = bytes.NewReader(b.buffer.Bytes())
+ }
+ return nil
+}
+
+// Read implements io.Reader
+func (b *FileBackedBuffer) Read(p []byte) (int, error) {
+ if err := b.switchToReader(); err != nil {
+ return 0, err
+ }
+
+ return b.reader.Read(p)
+}
+
+// ReadAt implements io.ReaderAt
+func (b *FileBackedBuffer) ReadAt(p []byte, off int64) (int, error) {
+ if err := b.switchToReader(); err != nil {
+ return 0, err
+ }
+
+ return b.reader.ReadAt(p, off)
+}
+
+// Seek implements io.Seeker
+func (b *FileBackedBuffer) Seek(offset int64, whence int) (int64, error) {
+ if err := b.switchToReader(); err != nil {
+ return 0, err
+ }
+
+ return b.reader.Seek(offset, whence)
+}
+
+// Close implements io.Closer
+func (b *FileBackedBuffer) Close() error {
+ if b.file != nil {
+ err := b.file.Close()
+ os.Remove(b.file.Name())
+ b.file = nil
+ return err
+ }
+ return nil
+}
diff --git a/modules/util/filebuffer/file_backed_buffer_test.go b/modules/util/filebuffer/file_backed_buffer_test.go
new file mode 100644
index 0000000..c56c1c6
--- /dev/null
+++ b/modules/util/filebuffer/file_backed_buffer_test.go
@@ -0,0 +1,36 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package filebuffer
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFileBackedBuffer(t *testing.T) {
+ cases := []struct {
+ MaxMemorySize int
+ Data string
+ }{
+ {5, "test"},
+ {5, "testtest"},
+ }
+
+ for _, c := range cases {
+ buf, err := CreateFromReader(strings.NewReader(c.Data), c.MaxMemorySize)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, len(c.Data), buf.Size())
+
+ data, err := io.ReadAll(buf)
+ require.NoError(t, err)
+ assert.Equal(t, c.Data, string(data))
+
+ require.NoError(t, buf.Close())
+ }
+}
diff --git a/modules/util/io.go b/modules/util/io.go
new file mode 100644
index 0000000..1559b01
--- /dev/null
+++ b/modules/util/io.go
@@ -0,0 +1,78 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "bytes"
+ "errors"
+ "io"
+)
+
+// ReadAtMost reads at most len(buf) bytes from r into buf.
+// It returns the number of bytes copied. n is only less than len(buf) if r provides fewer bytes.
+// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
+func ReadAtMost(r io.Reader, buf []byte) (n int, err error) {
+ n, err = io.ReadFull(r, buf)
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ err = nil
+ }
+ return n, err
+}
+
+// ReadWithLimit reads at most "limit" bytes from r into buf.
+// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
+func ReadWithLimit(r io.Reader, n int) (buf []byte, err error) {
+ return readWithLimit(r, 1024, n)
+}
+
+func readWithLimit(r io.Reader, batch, limit int) ([]byte, error) {
+ if limit <= batch {
+ buf := make([]byte, limit)
+ n, err := ReadAtMost(r, buf)
+ if err != nil {
+ return nil, err
+ }
+ return buf[:n], nil
+ }
+ res := bytes.NewBuffer(make([]byte, 0, batch))
+ bufFix := make([]byte, batch)
+ eof := false
+ for res.Len() < limit && !eof {
+ bufTmp := bufFix
+ if res.Len()+batch > limit {
+ bufTmp = bufFix[:limit-res.Len()]
+ }
+ n, err := io.ReadFull(r, bufTmp)
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ eof = true
+ } else if err != nil {
+ return nil, err
+ }
+ if _, err = res.Write(bufTmp[:n]); err != nil {
+ return nil, err
+ }
+ }
+ return res.Bytes(), nil
+}
+
+// ErrNotEmpty is an error reported when there is a non-empty reader
+var ErrNotEmpty = errors.New("not-empty")
+
+// IsEmptyReader reads a reader and ensures it is empty
+func IsEmptyReader(r io.Reader) (err error) {
+ var buf [1]byte
+
+ for {
+ n, err := r.Read(buf[:])
+ if err != nil {
+ if err == io.EOF {
+ return nil
+ }
+ return err
+ }
+ if n > 0 {
+ return ErrNotEmpty
+ }
+ }
+}
diff --git a/modules/util/io_test.go b/modules/util/io_test.go
new file mode 100644
index 0000000..870e713
--- /dev/null
+++ b/modules/util/io_test.go
@@ -0,0 +1,67 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type readerWithError struct {
+ buf *bytes.Buffer
+}
+
+func (r *readerWithError) Read(p []byte) (n int, err error) {
+ if r.buf.Len() < 2 {
+ return 0, errors.New("test error")
+ }
+ return r.buf.Read(p)
+}
+
+func TestReadWithLimit(t *testing.T) {
+ bs := []byte("0123456789abcdef")
+
+ // normal test
+ buf, err := readWithLimit(bytes.NewBuffer(bs), 5, 2)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("01"), buf)
+
+ buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 5)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("01234"), buf)
+
+ buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 6)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("012345"), buf)
+
+ buf, err = readWithLimit(bytes.NewBuffer(bs), 5, len(bs))
+ require.NoError(t, err)
+ assert.Equal(t, []byte("0123456789abcdef"), buf)
+
+ buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 100)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("0123456789abcdef"), buf)
+
+ // test with error
+ buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 10)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("0123456789"), buf)
+
+ buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 100)
+ require.ErrorContains(t, err, "test error")
+ assert.Empty(t, buf)
+
+ // test public function
+ buf, err = ReadWithLimit(bytes.NewBuffer(bs), 2)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("01"), buf)
+
+ buf, err = ReadWithLimit(bytes.NewBuffer(bs), 9999999)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("0123456789abcdef"), buf)
+}
diff --git a/modules/util/keypair.go b/modules/util/keypair.go
new file mode 100644
index 0000000..07f27bd
--- /dev/null
+++ b/modules/util/keypair.go
@@ -0,0 +1,57 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/pem"
+)
+
+// GenerateKeyPair generates a public and private keypair
+func GenerateKeyPair(bits int) (string, string, error) {
+ priv, _ := rsa.GenerateKey(rand.Reader, bits)
+ privPem := pemBlockForPriv(priv)
+ pubPem, err := pemBlockForPub(&priv.PublicKey)
+ if err != nil {
+ return "", "", err
+ }
+ return privPem, pubPem, nil
+}
+
+func pemBlockForPriv(priv *rsa.PrivateKey) string {
+ privBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(priv),
+ })
+ return string(privBytes)
+}
+
+func pemBlockForPub(pub *rsa.PublicKey) (string, error) {
+ pubASN1, err := x509.MarshalPKIXPublicKey(pub)
+ if err != nil {
+ return "", err
+ }
+ pubBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: pubASN1,
+ })
+ return string(pubBytes), nil
+}
+
+// CreatePublicKeyFingerprint creates a fingerprint of the given key.
+// The fingerprint is the sha256 sum of the PKIX structure of the key.
+func CreatePublicKeyFingerprint(key crypto.PublicKey) ([]byte, error) {
+ bytes, err := x509.MarshalPKIXPublicKey(key)
+ if err != nil {
+ return nil, err
+ }
+
+ checksum := sha256.Sum256(bytes)
+
+ return checksum[:], nil
+}
diff --git a/modules/util/keypair_test.go b/modules/util/keypair_test.go
new file mode 100644
index 0000000..ec9bca7
--- /dev/null
+++ b/modules/util/keypair_test.go
@@ -0,0 +1,62 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/pem"
+ "regexp"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestKeygen(t *testing.T) {
+ priv, pub, err := GenerateKeyPair(2048)
+ require.NoError(t, err)
+
+ assert.NotEmpty(t, priv)
+ assert.NotEmpty(t, pub)
+
+ assert.Regexp(t, regexp.MustCompile("^-----BEGIN RSA PRIVATE KEY-----.*"), priv)
+ assert.Regexp(t, regexp.MustCompile("^-----BEGIN PUBLIC KEY-----.*"), pub)
+}
+
+func TestSignUsingKeys(t *testing.T) {
+ priv, pub, err := GenerateKeyPair(2048)
+ require.NoError(t, err)
+
+ privPem, _ := pem.Decode([]byte(priv))
+ if privPem == nil || privPem.Type != "RSA PRIVATE KEY" {
+ t.Fatal("key is wrong type")
+ }
+
+ privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
+ require.NoError(t, err)
+
+ pubPem, _ := pem.Decode([]byte(pub))
+ if pubPem == nil || pubPem.Type != "PUBLIC KEY" {
+ t.Fatal("key failed to decode")
+ }
+
+ pubParsed, err := x509.ParsePKIXPublicKey(pubPem.Bytes)
+ require.NoError(t, err)
+
+ // Sign
+ msg := "activity pub is great!"
+ h := sha256.New()
+ h.Write([]byte(msg))
+ d := h.Sum(nil)
+ sig, err := rsa.SignPKCS1v15(rand.Reader, privParsed, crypto.SHA256, d)
+ require.NoError(t, err)
+
+ // Verify
+ err = rsa.VerifyPKCS1v15(pubParsed.(*rsa.PublicKey), crypto.SHA256, d, sig)
+ require.NoError(t, err)
+}
diff --git a/modules/util/legacy.go b/modules/util/legacy.go
new file mode 100644
index 0000000..2d4de01
--- /dev/null
+++ b/modules/util/legacy.go
@@ -0,0 +1,38 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "io"
+ "os"
+)
+
+// CopyFile copies file from source to target path.
+func CopyFile(src, dest string) error {
+ si, err := os.Lstat(src)
+ if err != nil {
+ return err
+ }
+
+ sr, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer sr.Close()
+
+ dw, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer dw.Close()
+
+ if _, err = io.Copy(dw, sr); err != nil {
+ return err
+ }
+
+ if err = os.Chtimes(dest, si.ModTime(), si.ModTime()); err != nil {
+ return err
+ }
+ return os.Chmod(dest, si.Mode())
+}
diff --git a/modules/util/legacy_test.go b/modules/util/legacy_test.go
new file mode 100644
index 0000000..62c2f8a
--- /dev/null
+++ b/modules/util/legacy_test.go
@@ -0,0 +1,38 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCopyFile(t *testing.T) {
+ testContent := []byte("hello")
+
+ tmpDir := os.TempDir()
+ now := time.Now()
+ srcFile := fmt.Sprintf("%s/copy-test-%d-src.txt", tmpDir, now.UnixMicro())
+ dstFile := fmt.Sprintf("%s/copy-test-%d-dst.txt", tmpDir, now.UnixMicro())
+
+ _ = os.Remove(srcFile)
+ _ = os.Remove(dstFile)
+ defer func() {
+ _ = os.Remove(srcFile)
+ _ = os.Remove(dstFile)
+ }()
+
+ err := os.WriteFile(srcFile, testContent, 0o777)
+ require.NoError(t, err)
+ err = CopyFile(srcFile, dstFile)
+ require.NoError(t, err)
+ dstContent, err := os.ReadFile(dstFile)
+ require.NoError(t, err)
+ assert.Equal(t, testContent, dstContent)
+}
diff --git a/modules/util/pack.go b/modules/util/pack.go
new file mode 100644
index 0000000..7fc074a
--- /dev/null
+++ b/modules/util/pack.go
@@ -0,0 +1,33 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "bytes"
+ "encoding/gob"
+)
+
+// PackData uses gob to encode the given data in sequence
+func PackData(data ...any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := gob.NewEncoder(&buf)
+ for _, datum := range data {
+ if err := enc.Encode(datum); err != nil {
+ return nil, err
+ }
+ }
+ return buf.Bytes(), nil
+}
+
+// UnpackData uses gob to decode the given data in sequence
+func UnpackData(buf []byte, data ...any) error {
+ r := bytes.NewReader(buf)
+ enc := gob.NewDecoder(r)
+ for _, datum := range data {
+ if err := enc.Decode(datum); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/modules/util/pack_test.go b/modules/util/pack_test.go
new file mode 100644
index 0000000..42ada89
--- /dev/null
+++ b/modules/util/pack_test.go
@@ -0,0 +1,28 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackAndUnpackData(t *testing.T) {
+ s := "string"
+ i := int64(4)
+ f := float32(4.1)
+
+ var s2 string
+ var i2 int64
+ var f2 float32
+
+ data, err := PackData(s, i, f)
+ require.NoError(t, err)
+
+ require.NoError(t, UnpackData(data, &s2, &i2, &f2))
+ require.NoError(t, UnpackData(data, &s2))
+ require.Error(t, UnpackData(data, &i2))
+ require.Error(t, UnpackData(data, &s2, &f2))
+}
diff --git a/modules/util/paginate.go b/modules/util/paginate.go
new file mode 100644
index 0000000..87f31b7
--- /dev/null
+++ b/modules/util/paginate.go
@@ -0,0 +1,33 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import "reflect"
+
+// PaginateSlice cut a slice as per pagination options
+// if page = 0 it do not paginate
+func PaginateSlice(list any, page, pageSize int) any {
+ if page <= 0 || pageSize <= 0 {
+ return list
+ }
+ if reflect.TypeOf(list).Kind() != reflect.Slice {
+ return list
+ }
+
+ listValue := reflect.ValueOf(list)
+
+ page--
+
+ if page*pageSize >= listValue.Len() {
+ return listValue.Slice(listValue.Len(), listValue.Len()).Interface()
+ }
+
+ listValue = listValue.Slice(page*pageSize, listValue.Len())
+
+ if listValue.Len() > pageSize {
+ return listValue.Slice(0, pageSize).Interface()
+ }
+
+ return listValue.Interface()
+}
diff --git a/modules/util/paginate_test.go b/modules/util/paginate_test.go
new file mode 100644
index 0000000..6e69dd1
--- /dev/null
+++ b/modules/util/paginate_test.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPaginateSlice(t *testing.T) {
+ stringSlice := []string{"a", "b", "c", "d", "e"}
+ result, ok := PaginateSlice(stringSlice, 1, 2).([]string)
+ assert.True(t, ok)
+ assert.EqualValues(t, []string{"a", "b"}, result)
+
+ result, ok = PaginateSlice(stringSlice, 100, 2).([]string)
+ assert.True(t, ok)
+ assert.EqualValues(t, []string{}, result)
+
+ result, ok = PaginateSlice(stringSlice, 3, 2).([]string)
+ assert.True(t, ok)
+ assert.EqualValues(t, []string{"e"}, result)
+
+ result, ok = PaginateSlice(stringSlice, 1, 0).([]string)
+ assert.True(t, ok)
+ assert.EqualValues(t, []string{"a", "b", "c", "d", "e"}, result)
+
+ result, ok = PaginateSlice(stringSlice, 1, -1).([]string)
+ assert.True(t, ok)
+ assert.EqualValues(t, []string{"a", "b", "c", "d", "e"}, result)
+
+ type Test struct {
+ Val int
+ }
+
+ testVar := []*Test{{Val: 2}, {Val: 3}, {Val: 4}}
+ testVar, ok = PaginateSlice(testVar, 1, 50).([]*Test)
+ assert.True(t, ok)
+ assert.EqualValues(t, []*Test{{Val: 2}, {Val: 3}, {Val: 4}}, testVar)
+
+ testVar, ok = PaginateSlice(testVar, 2, 2).([]*Test)
+ assert.True(t, ok)
+ assert.EqualValues(t, []*Test{{Val: 4}}, testVar)
+}
diff --git a/modules/util/path.go b/modules/util/path.go
new file mode 100644
index 0000000..185e7cf
--- /dev/null
+++ b/modules/util/path.go
@@ -0,0 +1,322 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+)
+
+// PathJoinRel joins the path elements into a single path, each element is cleaned by path.Clean separately.
+// It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed.
+// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
+//
+// empty => ``
+// `` => ``
+// `..` => `.`
+// `dir` => `dir`
+// `/dir/` => `dir`
+// `foo\..\bar` => `foo\..\bar`
+// {`foo`, ``, `bar`} => `foo/bar`
+// {`foo`, `..`, `bar`} => `foo/bar`
+func PathJoinRel(elem ...string) string {
+ elems := make([]string, len(elem))
+ for i, e := range elem {
+ if e == "" {
+ continue
+ }
+ elems[i] = path.Clean("/" + e)
+ }
+ p := path.Join(elems...)
+ if p == "" {
+ return ""
+ } else if p == "/" {
+ return "."
+ }
+ return p[1:]
+}
+
+// PathJoinRelX joins the path elements into a single path like PathJoinRel,
+// and convert all backslashes to slashes. (X means "extended", also means the combination of `\` and `/`).
+// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
+// It returns similar results as PathJoinRel except:
+//
+// `foo\..\bar` => `bar` (because it's processed as `foo/../bar`)
+//
+// All backslashes are handled as slashes, the result only contains slashes.
+func PathJoinRelX(elem ...string) string {
+ elems := make([]string, len(elem))
+ for i, e := range elem {
+ if e == "" {
+ continue
+ }
+ elems[i] = path.Clean("/" + strings.ReplaceAll(e, "\\", "/"))
+ }
+ return PathJoinRel(elems...)
+}
+
+const pathSeparator = string(os.PathSeparator)
+
+// FilePathJoinAbs joins the path elements into a single file path, each element is cleaned by filepath.Clean separately.
+// All slashes/backslashes are converted to path separators before cleaning, the result only contains path separators.
+// The first element must be an absolute path, caller should prepare the base path.
+// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
+// Like PathJoinRel, any redundant part (empty, relative dots, slashes) is removed.
+//
+// {`/foo`, ``, `bar`} => `/foo/bar`
+// {`/foo`, `..`, `bar`} => `/foo/bar`
+func FilePathJoinAbs(base string, sub ...string) string {
+ elems := make([]string, 1, len(sub)+1)
+
+ // POSIX filesystem can have `\` in file names. Windows: `\` and `/` are both used for path separators
+ // to keep the behavior consistent, we do not allow `\` in file names, replace all `\` with `/`
+ if isOSWindows() {
+ elems[0] = filepath.Clean(base)
+ } else {
+ elems[0] = filepath.Clean(strings.ReplaceAll(base, "\\", pathSeparator))
+ }
+ if !filepath.IsAbs(elems[0]) {
+ // This shouldn't happen. If there is really necessary to pass in relative path, return the full path with filepath.Abs() instead
+ panic(fmt.Sprintf("FilePathJoinAbs: %q (for path %v) is not absolute, do not guess a relative path based on current working directory", elems[0], elems))
+ }
+ for _, s := range sub {
+ if s == "" {
+ continue
+ }
+ if isOSWindows() {
+ elems = append(elems, filepath.Clean(pathSeparator+s))
+ } else {
+ elems = append(elems, filepath.Clean(pathSeparator+strings.ReplaceAll(s, "\\", pathSeparator)))
+ }
+ }
+ // the elems[0] must be an absolute path, just join them together
+ return filepath.Join(elems...)
+}
+
+// IsDir returns true if given path is a directory,
+// or returns false when it's a file or does not exist.
+func IsDir(dir string) (bool, error) {
+ f, err := os.Stat(dir)
+ if err == nil {
+ return f.IsDir(), nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+// IsFile returns true if given path is a file,
+// or returns false when it's a directory or does not exist.
+func IsFile(filePath string) (bool, error) {
+ f, err := os.Stat(filePath)
+ if err == nil {
+ return !f.IsDir(), nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+// IsExist checks whether a file or directory exists.
+// It returns false when the file or directory does not exist.
+func IsExist(path string) (bool, error) {
+ _, err := os.Stat(path)
+ if err == nil || os.IsExist(err) {
+ return true, nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool) ([]string, error) {
+ dir, err := os.Open(dirPath)
+ if err != nil {
+ return nil, err
+ }
+ defer dir.Close()
+
+ fis, err := dir.Readdir(0)
+ if err != nil {
+ return nil, err
+ }
+
+ statList := make([]string, 0)
+ for _, fi := range fis {
+ if CommonSkip(fi.Name()) {
+ continue
+ }
+
+ relPath := path.Join(recPath, fi.Name())
+ curPath := path.Join(dirPath, fi.Name())
+ if fi.IsDir() {
+ if includeDir {
+ statList = append(statList, relPath+"/")
+ }
+ s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks)
+ if err != nil {
+ return nil, err
+ }
+ statList = append(statList, s...)
+ } else if !isDirOnly {
+ statList = append(statList, relPath)
+ } else if followSymlinks && fi.Mode()&os.ModeSymlink != 0 {
+ link, err := os.Readlink(curPath)
+ if err != nil {
+ return nil, err
+ }
+
+ isDir, err := IsDir(link)
+ if err != nil {
+ return nil, err
+ }
+ if isDir {
+ if includeDir {
+ statList = append(statList, relPath+"/")
+ }
+ s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks)
+ if err != nil {
+ return nil, err
+ }
+ statList = append(statList, s...)
+ }
+ }
+ }
+ return statList, nil
+}
+
+// StatDir gathers information of given directory by depth-first.
+// It returns slice of file list and includes subdirectories if enabled;
+// it returns error and nil slice when error occurs in underlying functions,
+// or given path is not a directory or does not exist.
+//
+// Slice does not include given path itself.
+// If subdirectories is enabled, they will have suffix '/'.
+func StatDir(rootPath string, includeDir ...bool) ([]string, error) {
+ if isDir, err := IsDir(rootPath); err != nil {
+ return nil, err
+ } else if !isDir {
+ return nil, errors.New("not a directory or does not exist: " + rootPath)
+ }
+
+ isIncludeDir := false
+ if len(includeDir) != 0 {
+ isIncludeDir = includeDir[0]
+ }
+ return statDir(rootPath, "", isIncludeDir, false, false)
+}
+
+func isOSWindows() bool {
+ return runtime.GOOS == "windows"
+}
+
+var driveLetterRegexp = regexp.MustCompile("/[A-Za-z]:/")
+
+// FileURLToPath extracts the path information from a file://... url.
+// It returns an error only if the URL is not a file URL.
+func FileURLToPath(u *url.URL) (string, error) {
+ if u.Scheme != "file" {
+ return "", errors.New("URL scheme is not 'file': " + u.String())
+ }
+
+ path := u.Path
+
+ if !isOSWindows() {
+ return path, nil
+ }
+
+ // If it looks like there's a Windows drive letter at the beginning, strip off the leading slash.
+ if driveLetterRegexp.MatchString(path) {
+ return path[1:], nil
+ }
+ return path, nil
+}
+
+// HomeDir returns path of '~'(in Linux) on Windows,
+// it returns error when the variable does not exist.
+func HomeDir() (home string, err error) {
+ // TODO: some users run Gitea with mismatched uid and "HOME=xxx" (they set HOME=xxx by environment manually)
+ // TODO: when running gitea as a sub command inside git, the HOME directory is not the user's home directory
+ // so at the moment we can not use `user.Current().HomeDir`
+ if isOSWindows() {
+ home = os.Getenv("USERPROFILE")
+ if home == "" {
+ home = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH")
+ }
+ } else {
+ home = os.Getenv("HOME")
+ }
+
+ if home == "" {
+ return "", errors.New("cannot get home directory")
+ }
+
+ return home, nil
+}
+
+// CommonSkip will check a provided name to see if it represents file or directory that should not be watched
+func CommonSkip(name string) bool {
+ if name == "" {
+ return true
+ }
+
+ switch name[0] {
+ case '.':
+ return true
+ case 't', 'T':
+ return name[1:] == "humbs.db"
+ case 'd', 'D':
+ return name[1:] == "esktop.ini"
+ }
+
+ return false
+}
+
+// IsReadmeFileName reports whether name looks like a README file
+// based on its name.
+func IsReadmeFileName(name string) bool {
+ name = strings.ToLower(name)
+ if len(name) < 6 {
+ return false
+ } else if len(name) == 6 {
+ return name == "readme"
+ }
+ return name[:7] == "readme."
+}
+
+// IsReadmeFileExtension reports whether name looks like a README file
+// based on its name. It will look through the provided extensions and check if the file matches
+// one of the extensions and provide the index in the extension list.
+// If the filename is `readme.` with an unmatched extension it will match with the index equaling
+// the length of the provided extension list.
+// Note that the '.' should be provided in ext, e.g ".md"
+func IsReadmeFileExtension(name string, ext ...string) (int, bool) {
+ name = strings.ToLower(name)
+ if len(name) < 6 || name[:6] != "readme" {
+ return 0, false
+ }
+
+ for i, extension := range ext {
+ extension = strings.ToLower(extension)
+ if name[6:] == extension {
+ return i, true
+ }
+ }
+
+ if name[6] == '.' {
+ return len(ext), true
+ }
+
+ return 0, false
+}
diff --git a/modules/util/path_test.go b/modules/util/path_test.go
new file mode 100644
index 0000000..3699f05
--- /dev/null
+++ b/modules/util/path_test.go
@@ -0,0 +1,213 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "net/url"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFileURLToPath(t *testing.T) {
+ cases := []struct {
+ url string
+ expected string
+ haserror bool
+ windows bool
+ }{
+ // case 0
+ {
+ url: "",
+ haserror: true,
+ },
+ // case 1
+ {
+ url: "http://test.io",
+ haserror: true,
+ },
+ // case 2
+ {
+ url: "file:///path",
+ expected: "/path",
+ },
+ // case 3
+ {
+ url: "file:///C:/path",
+ expected: "C:/path",
+ windows: true,
+ },
+ }
+
+ for n, c := range cases {
+ if c.windows && runtime.GOOS != "windows" {
+ continue
+ }
+ u, _ := url.Parse(c.url)
+ p, err := FileURLToPath(u)
+ if c.haserror {
+ require.Error(t, err, "case %d: should return error", n)
+ } else {
+ require.NoError(t, err, "case %d: should not return error", n)
+ assert.Equal(t, c.expected, p, "case %d: should be equal", n)
+ }
+ }
+}
+
+func TestMisc_IsReadmeFileName(t *testing.T) {
+ trueTestCases := []string{
+ "readme",
+ "README",
+ "readME.mdown",
+ "README.md",
+ "readme.i18n.md",
+ }
+ falseTestCases := []string{
+ "test.md",
+ "wow.MARKDOWN",
+ "LOL.mDoWn",
+ "test",
+ "abcdefg",
+ "abcdefghijklmnopqrstuvwxyz",
+ "test.md.test",
+ "readmf",
+ }
+
+ for _, testCase := range trueTestCases {
+ assert.True(t, IsReadmeFileName(testCase))
+ }
+ for _, testCase := range falseTestCases {
+ assert.False(t, IsReadmeFileName(testCase))
+ }
+
+ type extensionTestcase struct {
+ name string
+ expected bool
+ idx int
+ }
+
+ exts := []string{".md", ".txt", ""}
+ testCasesExtensions := []extensionTestcase{
+ {
+ name: "readme",
+ expected: true,
+ idx: 2,
+ },
+ {
+ name: "readme.md",
+ expected: true,
+ idx: 0,
+ },
+ {
+ name: "README.md",
+ expected: true,
+ idx: 0,
+ },
+ {
+ name: "ReAdMe.Md",
+ expected: true,
+ idx: 0,
+ },
+ {
+ name: "readme.txt",
+ expected: true,
+ idx: 1,
+ },
+ {
+ name: "readme.doc",
+ expected: true,
+ idx: 3,
+ },
+ {
+ name: "readmee.md",
+ },
+ {
+ name: "readme..",
+ expected: true,
+ idx: 3,
+ },
+ }
+
+ for _, testCase := range testCasesExtensions {
+ idx, ok := IsReadmeFileExtension(testCase.name, exts...)
+ assert.Equal(t, testCase.expected, ok)
+ assert.Equal(t, testCase.idx, idx)
+ }
+}
+
+func TestCleanPath(t *testing.T) {
+ cases := []struct {
+ elems []string
+ expected string
+ }{
+ {[]string{}, ``},
+ {[]string{``}, ``},
+ {[]string{`..`}, `.`},
+ {[]string{`a`}, `a`},
+ {[]string{`/a/`}, `a`},
+ {[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
+ {[]string{`a\..\b`}, `a\..\b`},
+ {[]string{`a`, ``, `b`}, `a/b`},
+ {[]string{`a`, `..`, `b`}, `a/b`},
+ {[]string{`lfs`, `repo/..`, `user/../path`}, `lfs/path`},
+ }
+ for _, c := range cases {
+ assert.Equal(t, c.expected, PathJoinRel(c.elems...), "case: %v", c.elems)
+ }
+
+ cases = []struct {
+ elems []string
+ expected string
+ }{
+ {[]string{}, ``},
+ {[]string{``}, ``},
+ {[]string{`..`}, `.`},
+ {[]string{`a`}, `a`},
+ {[]string{`/a/`}, `a`},
+ {[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
+ {[]string{`a\..\b`}, `b`},
+ {[]string{`a`, ``, `b`}, `a/b`},
+ {[]string{`a`, `..`, `b`}, `a/b`},
+ {[]string{`lfs`, `repo/..`, `user/../path`}, `lfs/path`},
+ }
+ for _, c := range cases {
+ assert.Equal(t, c.expected, PathJoinRelX(c.elems...), "case: %v", c.elems)
+ }
+
+ // for POSIX only, but the result is similar on Windows, because the first element must be an absolute path
+ if isOSWindows() {
+ cases = []struct {
+ elems []string
+ expected string
+ }{
+ {[]string{`C:\..`}, `C:\`},
+ {[]string{`C:\a`}, `C:\a`},
+ {[]string{`C:\a/`}, `C:\a`},
+ {[]string{`C:\..\a\`, `../b`, `c\..`, `d`}, `C:\a\b\d`},
+ {[]string{`C:\a/..\b`}, `C:\b`},
+ {[]string{`C:\a`, ``, `b`}, `C:\a\b`},
+ {[]string{`C:\a`, `..`, `b`}, `C:\a\b`},
+ {[]string{`C:\lfs`, `repo/..`, `user/../path`}, `C:\lfs\path`},
+ }
+ } else {
+ cases = []struct {
+ elems []string
+ expected string
+ }{
+ {[]string{`/..`}, `/`},
+ {[]string{`/a`}, `/a`},
+ {[]string{`/a/`}, `/a`},
+ {[]string{`/../a/`, `../b`, `c/..`, `d`}, `/a/b/d`},
+ {[]string{`/a\..\b`}, `/b`},
+ {[]string{`/a`, ``, `b`}, `/a/b`},
+ {[]string{`/a`, `..`, `b`}, `/a/b`},
+ {[]string{`/lfs`, `repo/..`, `user/../path`}, `/lfs/path`},
+ }
+ }
+ for _, c := range cases {
+ assert.Equal(t, c.expected, FilePathJoinAbs(c.elems[0], c.elems[1:]...), "case: %v", c.elems)
+ }
+}
diff --git a/modules/util/remove.go b/modules/util/remove.go
new file mode 100644
index 0000000..d1e38fa
--- /dev/null
+++ b/modules/util/remove.go
@@ -0,0 +1,104 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "os"
+ "runtime"
+ "syscall"
+ "time"
+)
+
+const windowsSharingViolationError syscall.Errno = 32
+
+// Remove removes the named file or (empty) directory with at most 5 attempts.
+func Remove(name string) error {
+ var err error
+ for i := 0; i < 5; i++ {
+ err = os.Remove(name)
+ if err == nil {
+ break
+ }
+ unwrapped := err.(*os.PathError).Err
+ if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if unwrapped == syscall.ENOENT {
+ // it's already gone
+ return nil
+ }
+ }
+ return err
+}
+
+// RemoveAll removes the named file or (empty) directory with at most 5 attempts.
+func RemoveAll(name string) error {
+ var err error
+ for i := 0; i < 5; i++ {
+ err = os.RemoveAll(name)
+ if err == nil {
+ break
+ }
+ unwrapped := err.(*os.PathError).Err
+ if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if unwrapped == syscall.ENOENT {
+ // it's already gone
+ return nil
+ }
+ }
+ return err
+}
+
+// Rename renames (moves) oldpath to newpath with at most 5 attempts.
+func Rename(oldpath, newpath string) error {
+ var err error
+ for i := 0; i < 5; i++ {
+ err = os.Rename(oldpath, newpath)
+ if err == nil {
+ break
+ }
+ unwrapped := err.(*os.LinkError).Err
+ if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
+ // try again
+ <-time.After(100 * time.Millisecond)
+ continue
+ }
+
+ if i == 0 && os.IsNotExist(err) {
+ return err
+ }
+
+ if unwrapped == syscall.ENOENT {
+ // it's already gone
+ return nil
+ }
+ }
+ return err
+}
diff --git a/modules/util/rotatingfilewriter/writer.go b/modules/util/rotatingfilewriter/writer.go
new file mode 100644
index 0000000..c595f49
--- /dev/null
+++ b/modules/util/rotatingfilewriter/writer.go
@@ -0,0 +1,246 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rotatingfilewriter
+
+import (
+ "bufio"
+ "compress/gzip"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful/releasereopen"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type Options struct {
+ Rotate bool
+ MaximumSize int64
+ RotateDaily bool
+ KeepDays int
+ Compress bool
+ CompressionLevel int
+}
+
+type RotatingFileWriter struct {
+ mu sync.Mutex
+ fd *os.File
+
+ currentSize int64
+ openDate int
+
+ options Options
+
+ cancelReleaseReopen func()
+}
+
+var ErrorPrintf func(format string, args ...any)
+
+// errorf tries to print error messages. Since this writer could be used by a logger system, this is the last chance to show the error in some cases
+func errorf(format string, args ...any) {
+ if ErrorPrintf != nil {
+ ErrorPrintf("rotatingfilewriter: "+format+"\n", args...)
+ }
+}
+
+// Open creates a new rotating file writer.
+// Notice: if a file is opened by two rotators, there will be conflicts when rotating.
+// In the future, there should be "rotating file manager"
+func Open(filename string, options *Options) (*RotatingFileWriter, error) {
+ if options == nil {
+ options = &Options{}
+ }
+
+ rfw := &RotatingFileWriter{
+ options: *options,
+ }
+
+ if err := rfw.open(filename); err != nil {
+ return nil, err
+ }
+
+ rfw.cancelReleaseReopen = releasereopen.GetManager().Register(rfw)
+ return rfw, nil
+}
+
+func (rfw *RotatingFileWriter) Write(b []byte) (int, error) {
+ if rfw.options.Rotate && ((rfw.options.MaximumSize > 0 && rfw.currentSize >= rfw.options.MaximumSize) || (rfw.options.RotateDaily && time.Now().Day() != rfw.openDate)) {
+ if err := rfw.DoRotate(); err != nil {
+ // if this writer is used by a logger system, it's the logger system's responsibility to handle/show the error
+ return 0, err
+ }
+ }
+
+ n, err := rfw.fd.Write(b)
+ if err == nil {
+ rfw.currentSize += int64(n)
+ }
+ return n, err
+}
+
+func (rfw *RotatingFileWriter) Flush() error {
+ return rfw.fd.Sync()
+}
+
+func (rfw *RotatingFileWriter) Close() error {
+ rfw.mu.Lock()
+ if rfw.cancelReleaseReopen != nil {
+ rfw.cancelReleaseReopen()
+ rfw.cancelReleaseReopen = nil
+ }
+ rfw.mu.Unlock()
+ return rfw.fd.Close()
+}
+
+func (rfw *RotatingFileWriter) open(filename string) error {
+ fd, err := os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o660)
+ if err != nil {
+ return err
+ }
+
+ rfw.fd = fd
+
+ finfo, err := fd.Stat()
+ if err != nil {
+ return err
+ }
+ rfw.currentSize = finfo.Size()
+ rfw.openDate = finfo.ModTime().Day()
+
+ return nil
+}
+
+func (rfw *RotatingFileWriter) ReleaseReopen() error {
+ return errors.Join(
+ rfw.fd.Close(),
+ rfw.open(rfw.fd.Name()),
+ )
+}
+
+// DoRotate the log file creating a backup like xx.2013-01-01.2
+func (rfw *RotatingFileWriter) DoRotate() error {
+ if !rfw.options.Rotate {
+ return nil
+ }
+
+ rfw.mu.Lock()
+ defer rfw.mu.Unlock()
+
+ prefix := fmt.Sprintf("%s.%s.", rfw.fd.Name(), time.Now().Format("2006-01-02"))
+
+ var err error
+ fname := ""
+ for i := 1; err == nil && i <= 999; i++ {
+ fname = prefix + fmt.Sprintf("%03d", i)
+ _, err = os.Lstat(fname)
+ if rfw.options.Compress && err != nil {
+ _, err = os.Lstat(fname + ".gz")
+ }
+ }
+ // return error if the last file checked still existed
+ if err == nil {
+ return fmt.Errorf("cannot find free file to rename %s", rfw.fd.Name())
+ }
+
+ fd := rfw.fd
+ if err := fd.Close(); err != nil { // close file before rename
+ return err
+ }
+
+ if err := util.Rename(fd.Name(), fname); err != nil {
+ return err
+ }
+
+ if rfw.options.Compress {
+ go func() {
+ err := compressOldFile(fname, rfw.options.CompressionLevel)
+ if err != nil {
+ errorf("DoRotate: %v", err)
+ }
+ }()
+ }
+
+ if err := rfw.open(fd.Name()); err != nil {
+ return err
+ }
+
+ go deleteOldFiles(
+ filepath.Dir(fd.Name()),
+ filepath.Base(fd.Name()),
+ time.Now().AddDate(0, 0, -rfw.options.KeepDays),
+ )
+
+ return nil
+}
+
+func compressOldFile(fname string, compressionLevel int) error {
+ reader, err := os.Open(fname)
+ if err != nil {
+ return fmt.Errorf("compressOldFile: failed to open existing file %s: %w", fname, err)
+ }
+ defer reader.Close()
+
+ buffer := bufio.NewReader(reader)
+ fnameGz := fname + ".gz"
+ fw, err := os.OpenFile(fnameGz, os.O_WRONLY|os.O_CREATE, 0o660)
+ if err != nil {
+ return fmt.Errorf("compressOldFile: failed to open new file %s: %w", fnameGz, err)
+ }
+ defer fw.Close()
+
+ zw, err := gzip.NewWriterLevel(fw, compressionLevel)
+ if err != nil {
+ return fmt.Errorf("compressOldFile: failed to create gzip writer: %w", err)
+ }
+ defer zw.Close()
+
+ _, err = buffer.WriteTo(zw)
+ if err != nil {
+ _ = zw.Close()
+ _ = fw.Close()
+ _ = util.Remove(fname + ".gz")
+ return fmt.Errorf("compressOldFile: failed to write to gz file: %w", err)
+ }
+ _ = reader.Close()
+
+ err = util.Remove(fname)
+ if err != nil {
+ return fmt.Errorf("compressOldFile: failed to delete old file: %w", err)
+ }
+ return nil
+}
+
+func deleteOldFiles(dir, prefix string, removeBefore time.Time) {
+ err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) (returnErr error) {
+ defer func() {
+ if r := recover(); r != nil {
+ returnErr = fmt.Errorf("unable to delete old file '%s', error: %+v", path, r)
+ }
+ }()
+
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if info.ModTime().Before(removeBefore) {
+ if strings.HasPrefix(filepath.Base(path), prefix) {
+ return util.Remove(path)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ errorf("deleteOldFiles: failed to delete old file: %v", err)
+ }
+}
diff --git a/modules/util/rotatingfilewriter/writer_test.go b/modules/util/rotatingfilewriter/writer_test.go
new file mode 100644
index 0000000..5b3b351
--- /dev/null
+++ b/modules/util/rotatingfilewriter/writer_test.go
@@ -0,0 +1,49 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rotatingfilewriter
+
+import (
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompressOldFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ fname := filepath.Join(tmpDir, "test")
+ nonGzip := filepath.Join(tmpDir, "test-nonGzip")
+
+ f, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY, 0o660)
+ require.NoError(t, err)
+ ng, err := os.OpenFile(nonGzip, os.O_CREATE|os.O_WRONLY, 0o660)
+ require.NoError(t, err)
+
+ for i := 0; i < 999; i++ {
+ f.WriteString("This is a test file\n")
+ ng.WriteString("This is a test file\n")
+ }
+ f.Close()
+ ng.Close()
+
+ err = compressOldFile(fname, gzip.DefaultCompression)
+ require.NoError(t, err)
+
+ _, err = os.Lstat(fname + ".gz")
+ require.NoError(t, err)
+
+ f, err = os.Open(fname + ".gz")
+ require.NoError(t, err)
+ zr, err := gzip.NewReader(f)
+ require.NoError(t, err)
+ data, err := io.ReadAll(zr)
+ require.NoError(t, err)
+ original, err := os.ReadFile(nonGzip)
+ require.NoError(t, err)
+ assert.Equal(t, original, data)
+}
diff --git a/modules/util/sanitize.go b/modules/util/sanitize.go
new file mode 100644
index 0000000..0dd8b34
--- /dev/null
+++ b/modules/util/sanitize.go
@@ -0,0 +1,72 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "bytes"
+ "unicode"
+)
+
+type sanitizedError struct {
+ err error
+}
+
+func (err sanitizedError) Error() string {
+ return SanitizeCredentialURLs(err.err.Error())
+}
+
+func (err sanitizedError) Unwrap() error {
+ return err.err
+}
+
+// SanitizeErrorCredentialURLs wraps the error and make sure the returned error message doesn't contain sensitive credentials in URLs
+func SanitizeErrorCredentialURLs(err error) error {
+ return sanitizedError{err: err}
+}
+
+const userPlaceholder = "sanitized-credential"
+
+var schemeSep = []byte("://")
+
+// SanitizeCredentialURLs remove all credentials in URLs (starting with "scheme://") for the input string: "https://user:pass@domain.com" => "https://sanitized-credential@domain.com"
+func SanitizeCredentialURLs(s string) string {
+ bs := UnsafeStringToBytes(s)
+ schemeSepPos := bytes.Index(bs, schemeSep)
+ if schemeSepPos == -1 || bytes.IndexByte(bs[schemeSepPos:], '@') == -1 {
+ return s // fast return if there is no URL scheme or no userinfo
+ }
+ out := make([]byte, 0, len(bs)+len(userPlaceholder))
+ for schemeSepPos != -1 {
+ schemeSepPos += 3 // skip the "://"
+ sepAtPos := -1 // the possible '@' position: "https://foo@[^here]host"
+ sepEndPos := schemeSepPos // the possible end position: "The https://host[^here] in log for test"
+ sepLoop:
+ for ; sepEndPos < len(bs); sepEndPos++ {
+ c := bs[sepEndPos]
+ if ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') || ('0' <= c && c <= '9') {
+ continue
+ }
+ switch c {
+ case '@':
+ sepAtPos = sepEndPos
+ case '-', '.', '_', '~', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '%':
+ continue // due to RFC 3986, userinfo can contain - . _ ~ ! $ & ' ( ) * + , ; = : and any percent-encoded chars
+ default:
+ break sepLoop // if it is an invalid char for URL (eg: space, '/', and others), stop the loop
+ }
+ }
+ // if there is '@', and the string is like "s://u@h", then hide the "u" part
+ if sepAtPos != -1 && (schemeSepPos >= 4 && unicode.IsLetter(rune(bs[schemeSepPos-4]))) && sepAtPos-schemeSepPos > 0 && sepEndPos-sepAtPos > 0 {
+ out = append(out, bs[:schemeSepPos]...)
+ out = append(out, userPlaceholder...)
+ out = append(out, bs[sepAtPos:sepEndPos]...)
+ } else {
+ out = append(out, bs[:sepEndPos]...)
+ }
+ bs = bs[sepEndPos:]
+ schemeSepPos = bytes.Index(bs, schemeSep)
+ }
+ out = append(out, bs...)
+ return UnsafeBytesToString(out)
+}
diff --git a/modules/util/sanitize_test.go b/modules/util/sanitize_test.go
new file mode 100644
index 0000000..0bcfd45
--- /dev/null
+++ b/modules/util/sanitize_test.go
@@ -0,0 +1,74 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSanitizeErrorCredentialURLs(t *testing.T) {
+ err := errors.New("error with https://a@b.com")
+ se := SanitizeErrorCredentialURLs(err)
+ assert.Equal(t, "error with https://"+userPlaceholder+"@b.com", se.Error())
+}
+
+func TestSanitizeCredentialURLs(t *testing.T) {
+ cases := []struct {
+ input string
+ expected string
+ }{
+ {
+ "https://github.com/go-gitea/test_repo.git",
+ "https://github.com/go-gitea/test_repo.git",
+ },
+ {
+ "https://mytoken@github.com/go-gitea/test_repo.git",
+ "https://" + userPlaceholder + "@github.com/go-gitea/test_repo.git",
+ },
+ {
+ "https://user:password@github.com/go-gitea/test_repo.git",
+ "https://" + userPlaceholder + "@github.com/go-gitea/test_repo.git",
+ },
+ {
+ "ftp://x@",
+ "ftp://" + userPlaceholder + "@",
+ },
+ {
+ "ftp://x/@",
+ "ftp://x/@",
+ },
+ {
+ "ftp://u@x/@", // test multiple @ chars
+ "ftp://" + userPlaceholder + "@x/@",
+ },
+ {
+ "😊ftp://u@x😊", // test unicode
+ "😊ftp://" + userPlaceholder + "@x😊",
+ },
+ {
+ "://@",
+ "://@",
+ },
+ {
+ "//u:p@h", // do not process URLs without explicit scheme, they are not treated as "valid" URLs because there is no scheme context in string
+ "//u:p@h",
+ },
+ {
+ "s://u@h", // the minimal pattern to be sanitized
+ "s://" + userPlaceholder + "@h",
+ },
+ {
+ "URLs in log https://u:b@h and https://u:b@h:80/, with https://h.com and u@h.com",
+ "URLs in log https://" + userPlaceholder + "@h and https://" + userPlaceholder + "@h:80/, with https://h.com and u@h.com",
+ },
+ }
+
+ for n, c := range cases {
+ result := SanitizeCredentialURLs(c.input)
+ assert.Equal(t, c.expected, result, "case %d: error should match", n)
+ }
+}
diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go
new file mode 100644
index 0000000..ad0fb1a
--- /dev/null
+++ b/modules/util/sec_to_time.go
@@ -0,0 +1,81 @@
+// Copyright 2022 Gitea. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SecToTime converts an amount of seconds to a human-readable string. E.g.
+// 66s -> 1 minute 6 seconds
+// 52410s -> 14 hours 33 minutes
+// 563418 -> 6 days 12 hours
+// 1563418 -> 2 weeks 4 days
+// 3937125s -> 1 month 2 weeks
+// 45677465s -> 1 year 6 months
+func SecToTime(durationVal any) string {
+ duration, _ := ToInt64(durationVal)
+
+ formattedTime := ""
+
+ // The following four variables are calculated by taking
+ // into account the previously calculated variables, this avoids
+ // pitfalls when using remainders. As that could lead to incorrect
+ // results when the calculated number equals the quotient number.
+ remainingDays := duration / (60 * 60 * 24)
+ years := remainingDays / 365
+ remainingDays -= years * 365
+ months := remainingDays * 12 / 365
+ remainingDays -= months * 365 / 12
+ weeks := remainingDays / 7
+ remainingDays -= weeks * 7
+ days := remainingDays
+
+ // The following three variables are calculated without depending
+ // on the previous calculated variables.
+ hours := (duration / 3600) % 24
+ minutes := (duration / 60) % 60
+ seconds := duration % 60
+
+ // Extract only the relevant information of the time
+ // If the time is greater than a year, it makes no sense to display seconds.
+ switch {
+ case years > 0:
+ formattedTime = formatTime(years, "year", formattedTime)
+ formattedTime = formatTime(months, "month", formattedTime)
+ case months > 0:
+ formattedTime = formatTime(months, "month", formattedTime)
+ formattedTime = formatTime(weeks, "week", formattedTime)
+ case weeks > 0:
+ formattedTime = formatTime(weeks, "week", formattedTime)
+ formattedTime = formatTime(days, "day", formattedTime)
+ case days > 0:
+ formattedTime = formatTime(days, "day", formattedTime)
+ formattedTime = formatTime(hours, "hour", formattedTime)
+ case hours > 0:
+ formattedTime = formatTime(hours, "hour", formattedTime)
+ formattedTime = formatTime(minutes, "minute", formattedTime)
+ default:
+ formattedTime = formatTime(minutes, "minute", formattedTime)
+ formattedTime = formatTime(seconds, "second", formattedTime)
+ }
+
+ // The formatTime() function always appends a space at the end. This will be trimmed
+ return strings.TrimRight(formattedTime, " ")
+}
+
+// formatTime appends the given value to the existing forammattedTime. E.g:
+// formattedTime = "1 year"
+// input: value = 3, name = "month"
+// output will be "1 year 3 months "
+func formatTime(value int64, name, formattedTime string) string {
+ if value == 1 {
+ formattedTime = fmt.Sprintf("%s1 %s ", formattedTime, name)
+ } else if value > 1 {
+ formattedTime = fmt.Sprintf("%s%d %ss ", formattedTime, value, name)
+ }
+
+ return formattedTime
+}
diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go
new file mode 100644
index 0000000..4d1213a
--- /dev/null
+++ b/modules/util/sec_to_time_test.go
@@ -0,0 +1,30 @@
+// Copyright 2022 Gitea. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSecToTime(t *testing.T) {
+ second := int64(1)
+ minute := 60 * second
+ hour := 60 * minute
+ day := 24 * hour
+ year := 365 * day
+
+ assert.Equal(t, "1 minute 6 seconds", SecToTime(minute+6*second))
+ assert.Equal(t, "1 hour", SecToTime(hour))
+ assert.Equal(t, "1 hour", SecToTime(hour+second))
+ assert.Equal(t, "14 hours 33 minutes", SecToTime(14*hour+33*minute+30*second))
+ assert.Equal(t, "6 days 12 hours", SecToTime(6*day+12*hour+30*minute+18*second))
+ assert.Equal(t, "2 weeks 4 days", SecToTime((2*7+4)*day+2*hour+16*minute+58*second))
+ assert.Equal(t, "4 weeks", SecToTime(4*7*day))
+ assert.Equal(t, "4 weeks 1 day", SecToTime((4*7+1)*day))
+ assert.Equal(t, "1 month 2 weeks", SecToTime((6*7+3)*day+13*hour+38*minute+45*second))
+ assert.Equal(t, "11 months", SecToTime(year-25*day))
+ assert.Equal(t, "1 year 5 months", SecToTime(year+163*day+10*hour+11*minute+5*second))
+}
diff --git a/modules/util/shellquote.go b/modules/util/shellquote.go
new file mode 100644
index 0000000..434dc42
--- /dev/null
+++ b/modules/util/shellquote.go
@@ -0,0 +1,101 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import "strings"
+
+// Bash has the definition of a metacharacter:
+// * A character that, when unquoted, separates words.
+// A metacharacter is one of: " \t\n|&;()<>"
+//
+// The following characters also have addition special meaning when unescaped:
+// * ‘${[*?!"'`\’
+//
+// Double Quotes preserve the literal value of all characters with then quotes
+// excepting: ‘$’, ‘`’, ‘\’, and, when history expansion is enabled, ‘!’.
+// The backslash retains its special meaning only when followed by one of the
+// following characters: ‘$’, ‘`’, ‘"’, ‘\’, or newline.
+// Backslashes preceding characters without a special meaning are left
+// unmodified. A double quote may be quoted within double quotes by preceding
+// it with a backslash. If enabled, history expansion will be performed unless
+// an ‘!’ appearing in double quotes is escaped using a backslash. The
+// backslash preceding the ‘!’ is not removed.
+//
+// -> This means that `!\n` cannot be safely expressed in `"`.
+//
+// Looking at the man page for Dash and ash the situation is similar.
+//
+// Now zsh requires that ‘}’, and ‘]’ are also enclosed in doublequotes or escaped
+//
+// Single quotes escape everything except a ‘'’
+//
+// There's one other gotcha - ‘~’ at the start of a string needs to be expanded
+// because people always expect that - of course if there is a special character before '/'
+// this is not going to work
+
+const (
+ tildePrefix = '~'
+ needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
+ needsSingleQuote = "!\n"
+)
+
+var (
+ doubleQuoteEscaper = strings.NewReplacer(`$`, `\$`, "`", "\\`", `"`, `\"`, `\`, `\\`)
+ singleQuoteEscaper = strings.NewReplacer(`'`, `'\''`)
+ singleQuoteCoalescer = strings.NewReplacer(`''\'`, `\'`, `\'''`, `\'`)
+)
+
+// ShellEscape will escape the provided string.
+// We can't just use go-shellquote here because our preferences for escaping differ from those in that we want:
+//
+// * If the string doesn't require any escaping just leave it as it is.
+// * If the string requires any escaping prefer double quote escaping
+// * If we have ! or newlines then we need to use single quote escaping
+func ShellEscape(toEscape string) string {
+ if len(toEscape) == 0 {
+ return toEscape
+ }
+
+ start := 0
+
+ if toEscape[0] == tildePrefix {
+ // We're in the forcibly non-escaped section...
+ idx := strings.IndexRune(toEscape, '/')
+ if idx < 0 {
+ idx = len(toEscape)
+ } else {
+ idx++
+ }
+ if !strings.ContainsAny(toEscape[:idx], needsEscape) {
+ // We'll assume that they intend ~ expansion to occur
+ start = idx
+ }
+ }
+
+ // Now for simplicity we'll look at the rest of the string
+ if !strings.ContainsAny(toEscape[start:], needsEscape) {
+ return toEscape
+ }
+
+ // OK we have to do some escaping
+ sb := &strings.Builder{}
+ _, _ = sb.WriteString(toEscape[:start])
+
+ // Do we have any characters which absolutely need to be within single quotes - that is simply ! or \n?
+ if strings.ContainsAny(toEscape[start:], needsSingleQuote) {
+ // We need to single quote escape.
+ sb2 := &strings.Builder{}
+ _, _ = sb2.WriteRune('\'')
+ _, _ = singleQuoteEscaper.WriteString(sb2, toEscape[start:])
+ _, _ = sb2.WriteRune('\'')
+ _, _ = singleQuoteCoalescer.WriteString(sb, sb2.String())
+ return sb.String()
+ }
+
+ // OK we can just use " just escape the things that need escaping
+ _, _ = sb.WriteRune('"')
+ _, _ = doubleQuoteEscaper.WriteString(sb, toEscape[start:])
+ _, _ = sb.WriteRune('"')
+ return sb.String()
+}
diff --git a/modules/util/shellquote_test.go b/modules/util/shellquote_test.go
new file mode 100644
index 0000000..969998c
--- /dev/null
+++ b/modules/util/shellquote_test.go
@@ -0,0 +1,91 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import "testing"
+
+func TestShellEscape(t *testing.T) {
+ tests := []struct {
+ name string
+ toEscape string
+ want string
+ }{
+ {
+ "Simplest case - nothing to escape",
+ "a/b/c/d",
+ "a/b/c/d",
+ }, {
+ "Prefixed tilde - with normal stuff - should not escape",
+ "~/src/go/gitea/gitea",
+ "~/src/go/gitea/gitea",
+ }, {
+ "Typical windows path with spaces - should get doublequote escaped",
+ `C:\Program Files\Gitea v1.13 - I like lots of spaces\gitea`,
+ `"C:\\Program Files\\Gitea v1.13 - I like lots of spaces\\gitea"`,
+ }, {
+ "Forward-slashed windows path with spaces - should get doublequote escaped",
+ "C:/Program Files/Gitea v1.13 - I like lots of spaces/gitea",
+ `"C:/Program Files/Gitea v1.13 - I like lots of spaces/gitea"`,
+ }, {
+ "Prefixed tilde - but then a space filled path",
+ "~git/Gitea v1.13/gitea",
+ `~git/"Gitea v1.13/gitea"`,
+ }, {
+ "Bangs are unfortunately not predictable so need to be singlequoted",
+ "C:/Program Files/Gitea!/gitea",
+ `'C:/Program Files/Gitea!/gitea'`,
+ }, {
+ "Newlines are just irritating",
+ "/home/git/Gitea\n\nWHY-WOULD-YOU-DO-THIS\n\nGitea/gitea",
+ "'/home/git/Gitea\n\nWHY-WOULD-YOU-DO-THIS\n\nGitea/gitea'",
+ }, {
+ "Similarly we should nicely handle multiple single quotes if we have to single-quote",
+ "'!''!'''!''!'!'",
+ `\''!'\'\''!'\'\'\''!'\'\''!'\''!'\'`,
+ }, {
+ "Double quote < ...",
+ "~/<gitea",
+ "~/\"<gitea\"",
+ }, {
+ "Double quote > ...",
+ "~/gitea>",
+ "~/\"gitea>\"",
+ }, {
+ "Double quote and escape $ ...",
+ "~/$gitea",
+ "~/\"\\$gitea\"",
+ }, {
+ "Double quote {...",
+ "~/{gitea",
+ "~/\"{gitea\"",
+ }, {
+ "Double quote }...",
+ "~/gitea}",
+ "~/\"gitea}\"",
+ }, {
+ "Double quote ()...",
+ "~/(gitea)",
+ "~/\"(gitea)\"",
+ }, {
+ "Double quote and escape `...",
+ "~/gitea`",
+ "~/\"gitea\\`\"",
+ }, {
+ "Double quotes can handle a number of things without having to escape them but not everything ...",
+ "~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
+ "~/\"<gitea> \\${gitea} \\`gitea\\` [gitea] (gitea) \\\"gitea\\\" \\\\gitea\\\\ 'gitea'\"",
+ }, {
+ "Single quotes don't need to escape except for '...",
+ "~/<gitea> ${gitea} `gitea` (gitea) !gitea! \"gitea\" \\gitea\\ 'gitea'",
+ "~/'<gitea> ${gitea} `gitea` (gitea) !gitea! \"gitea\" \\gitea\\ '\\''gitea'\\'",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ShellEscape(tt.toEscape); got != tt.want {
+ t.Errorf("ShellEscape(%q):\nGot: %s\nWanted: %s", tt.toEscape, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/util/slice.go b/modules/util/slice.go
new file mode 100644
index 0000000..9c878c2
--- /dev/null
+++ b/modules/util/slice.go
@@ -0,0 +1,73 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "cmp"
+ "slices"
+ "strings"
+)
+
+// SliceContainsString sequential searches if string exists in slice.
+func SliceContainsString(slice []string, target string, insensitive ...bool) bool {
+ if len(insensitive) != 0 && insensitive[0] {
+ target = strings.ToLower(target)
+ return slices.ContainsFunc(slice, func(t string) bool { return strings.ToLower(t) == target })
+ }
+
+ return slices.Contains(slice, target)
+}
+
+// SliceSortedEqual returns true if the two slices will be equal when they get sorted.
+// It doesn't require that the slices have been sorted, and it doesn't sort them either.
+func SliceSortedEqual[T comparable](s1, s2 []T) bool {
+ if len(s1) != len(s2) {
+ return false
+ }
+
+ counts := make(map[T]int, len(s1))
+ for _, v := range s1 {
+ counts[v]++
+ }
+ for _, v := range s2 {
+ counts[v]--
+ }
+
+ for _, v := range counts {
+ if v != 0 {
+ return false
+ }
+ }
+ return true
+}
+
+// SliceRemoveAll removes all the target elements from the slice.
+func SliceRemoveAll[T comparable](slice []T, target T) []T {
+ return slices.DeleteFunc(slice, func(t T) bool { return t == target })
+}
+
+// Sorted returns the sorted slice
+// Note: The parameter is sorted inline.
+func Sorted[S ~[]E, E cmp.Ordered](values S) S {
+ slices.Sort(values)
+ return values
+}
+
+// TODO: Replace with "maps.Values" once available, current it only in golang.org/x/exp/maps but not in standard library
+func ValuesOfMap[K comparable, V any](m map[K]V) []V {
+ values := make([]V, 0, len(m))
+ for _, v := range m {
+ values = append(values, v)
+ }
+ return values
+}
+
+// TODO: Replace with "maps.Keys" once available, current it only in golang.org/x/exp/maps but not in standard library
+func KeysOfMap[K comparable, V any](m map[K]V) []K {
+ keys := make([]K, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ return keys
+}
diff --git a/modules/util/slice_test.go b/modules/util/slice_test.go
new file mode 100644
index 0000000..a910f5e
--- /dev/null
+++ b/modules/util/slice_test.go
@@ -0,0 +1,55 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSliceContainsString(t *testing.T) {
+ assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "a"))
+ assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "b"))
+ assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "A", true))
+ assert.True(t, SliceContainsString([]string{"C", "B", "A", "B"}, "a", true))
+
+ assert.False(t, SliceContainsString([]string{"c", "b", "a", "b"}, "z"))
+ assert.False(t, SliceContainsString([]string{"c", "b", "a", "b"}, "A"))
+ assert.False(t, SliceContainsString([]string{}, "a"))
+ assert.False(t, SliceContainsString(nil, "a"))
+}
+
+func TestSliceSortedEqual(t *testing.T) {
+ assert.True(t, SliceSortedEqual([]int{2, 0, 2, 3}, []int{2, 0, 2, 3}))
+ assert.True(t, SliceSortedEqual([]int{3, 0, 2, 2}, []int{2, 0, 2, 3}))
+ assert.True(t, SliceSortedEqual([]int{}, []int{}))
+ assert.True(t, SliceSortedEqual([]int(nil), nil))
+ assert.True(t, SliceSortedEqual([]int(nil), []int{}))
+ assert.True(t, SliceSortedEqual([]int{}, []int{}))
+
+ assert.True(t, SliceSortedEqual([]string{"2", "0", "2", "3"}, []string{"2", "0", "2", "3"}))
+ assert.True(t, SliceSortedEqual([]float64{2, 0, 2, 3}, []float64{2, 0, 2, 3}))
+ assert.True(t, SliceSortedEqual([]bool{false, true, false}, []bool{false, true, false}))
+
+ assert.False(t, SliceSortedEqual([]int{2, 0, 2}, []int{2, 0, 2, 3}))
+ assert.False(t, SliceSortedEqual([]int{}, []int{2, 0, 2, 3}))
+ assert.False(t, SliceSortedEqual(nil, []int{2, 0, 2, 3}))
+ assert.False(t, SliceSortedEqual([]int{2, 0, 2, 4}, []int{2, 0, 2, 3}))
+ assert.False(t, SliceSortedEqual([]int{2, 0, 0, 3}, []int{2, 0, 2, 3}))
+}
+
+func TestSliceRemoveAll(t *testing.T) {
+ assert.ElementsMatch(t, []int{2, 2, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 0))
+ assert.ElementsMatch(t, []int{0, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 2))
+ assert.Empty(t, SliceRemoveAll([]int{0, 0, 0, 0}, 0))
+ assert.ElementsMatch(t, []int{2, 0, 2, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 4))
+ assert.Empty(t, SliceRemoveAll([]int{}, 0))
+ assert.ElementsMatch(t, []int(nil), SliceRemoveAll([]int(nil), 0))
+ assert.Empty(t, SliceRemoveAll([]int{}, 0))
+
+ assert.ElementsMatch(t, []string{"2", "2", "3"}, SliceRemoveAll([]string{"2", "0", "2", "3"}, "0"))
+ assert.ElementsMatch(t, []float64{2, 2, 3}, SliceRemoveAll([]float64{2, 0, 2, 3}, 0))
+ assert.ElementsMatch(t, []bool{false, false}, SliceRemoveAll([]bool{false, true, false}, true))
+}
diff --git a/modules/util/string.go b/modules/util/string.go
new file mode 100644
index 0000000..cf50f59
--- /dev/null
+++ b/modules/util/string.go
@@ -0,0 +1,97 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import "unsafe"
+
+func isSnakeCaseUpper(c byte) bool {
+ return 'A' <= c && c <= 'Z'
+}
+
+func isSnakeCaseLowerOrNumber(c byte) bool {
+ return 'a' <= c && c <= 'z' || '0' <= c && c <= '9'
+}
+
+// ToSnakeCase convert the input string to snake_case format.
+//
+// Some samples.
+//
+// "FirstName" => "first_name"
+// "HTTPServer" => "http_server"
+// "NoHTTPS" => "no_https"
+// "GO_PATH" => "go_path"
+// "GO PATH" => "go_path" // space is converted to underscore.
+// "GO-PATH" => "go_path" // hyphen is converted to underscore.
+func ToSnakeCase(input string) string {
+ if len(input) == 0 {
+ return ""
+ }
+
+ var res []byte
+ if len(input) == 1 {
+ c := input[0]
+ if isSnakeCaseUpper(c) {
+ res = []byte{c + 'a' - 'A'}
+ } else if isSnakeCaseLowerOrNumber(c) {
+ res = []byte{c}
+ } else {
+ res = []byte{'_'}
+ }
+ } else {
+ res = make([]byte, 0, len(input)*4/3)
+ pos := 0
+ needSep := false
+ for pos < len(input) {
+ c := input[pos]
+ if c >= 0x80 {
+ res = append(res, c)
+ pos++
+ continue
+ }
+ isUpper := isSnakeCaseUpper(c)
+ if isUpper || isSnakeCaseLowerOrNumber(c) {
+ end := pos + 1
+ if isUpper {
+ // skip the following upper letters
+ for end < len(input) && isSnakeCaseUpper(input[end]) {
+ end++
+ }
+ if end-pos > 1 && end < len(input) && isSnakeCaseLowerOrNumber(input[end]) {
+ end--
+ }
+ }
+ // skip the following lower or number letters
+ for end < len(input) && (isSnakeCaseLowerOrNumber(input[end]) || input[end] >= 0x80) {
+ end++
+ }
+ if needSep {
+ res = append(res, '_')
+ }
+ res = append(res, input[pos:end]...)
+ pos = end
+ needSep = true
+ } else {
+ res = append(res, '_')
+ pos++
+ needSep = false
+ }
+ }
+ for i := 0; i < len(res); i++ {
+ if isSnakeCaseUpper(res[i]) {
+ res[i] += 'a' - 'A'
+ }
+ }
+ }
+ return UnsafeBytesToString(res)
+}
+
+// UnsafeBytesToString uses Go's unsafe package to convert a byte slice to a string.
+func UnsafeBytesToString(b []byte) string {
+ return unsafe.String(unsafe.SliceData(b), len(b))
+}
+
+// UnsafeStringToBytes uses Go's unsafe package to convert a string to a byte slice.
+func UnsafeStringToBytes(s string) []byte {
+ return unsafe.Slice(unsafe.StringData(s), len(s))
+}
diff --git a/modules/util/string_test.go b/modules/util/string_test.go
new file mode 100644
index 0000000..0a4a8bb
--- /dev/null
+++ b/modules/util/string_test.go
@@ -0,0 +1,47 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestToSnakeCase(t *testing.T) {
+ cases := map[string]string{
+ // all old cases from the legacy package
+ "HTTPServer": "http_server",
+ "_camelCase": "_camel_case",
+ "NoHTTPS": "no_https",
+ "Wi_thF": "wi_th_f",
+ "_AnotherTES_TCaseP": "_another_tes_t_case_p",
+ "ALL": "all",
+ "_HELLO_WORLD_": "_hello_world_",
+ "HELLO_WORLD": "hello_world",
+ "HELLO____WORLD": "hello____world",
+ "TW": "tw",
+ "_C": "_c",
+
+ " sentence case ": "__sentence_case__",
+ " Mixed-hyphen case _and SENTENCE_case and UPPER-case": "_mixed_hyphen_case__and_sentence_case_and_upper_case",
+
+ // new cases
+ " ": "_",
+ "A": "a",
+ "A0": "a0",
+ "a0": "a0",
+ "Aa0": "aa0",
+ "å•Š": "å•Š",
+ "Aå•Š": "aå•Š",
+ "Aaå•Šb": "aaå•Šb",
+ "Aå•ŠB": "aå•Š_b",
+ "Aaå•ŠB": "aaå•Š_b",
+ "TheCase2": "the_case2",
+ "ObjIDs": "obj_i_ds", // the strange database column name which already exists
+ }
+ for input, expected := range cases {
+ assert.Equal(t, expected, ToSnakeCase(input))
+ }
+}
diff --git a/modules/util/timer.go b/modules/util/timer.go
new file mode 100644
index 0000000..f9a7950
--- /dev/null
+++ b/modules/util/timer.go
@@ -0,0 +1,36 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "sync"
+ "time"
+)
+
+func Debounce(d time.Duration) func(f func()) {
+ type debouncer struct {
+ mu sync.Mutex
+ t *time.Timer
+ }
+ db := &debouncer{}
+
+ return func(f func()) {
+ db.mu.Lock()
+ defer db.mu.Unlock()
+
+ if db.t != nil {
+ db.t.Stop()
+ }
+ var trigger *time.Timer
+ trigger = time.AfterFunc(d, func() {
+ db.mu.Lock()
+ defer db.mu.Unlock()
+ if trigger == db.t {
+ f()
+ db.t = nil
+ }
+ })
+ db.t = trigger
+ }
+}
diff --git a/modules/util/timer_test.go b/modules/util/timer_test.go
new file mode 100644
index 0000000..602800c
--- /dev/null
+++ b/modules/util/timer_test.go
@@ -0,0 +1,30 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDebounce(t *testing.T) {
+ var c int64
+ d := Debounce(50 * time.Millisecond)
+ d(func() { atomic.AddInt64(&c, 1) })
+ assert.EqualValues(t, 0, atomic.LoadInt64(&c))
+ d(func() { atomic.AddInt64(&c, 1) })
+ d(func() { atomic.AddInt64(&c, 1) })
+ time.Sleep(100 * time.Millisecond)
+ assert.EqualValues(t, 1, atomic.LoadInt64(&c))
+ d(func() { atomic.AddInt64(&c, 1) })
+ assert.EqualValues(t, 1, atomic.LoadInt64(&c))
+ d(func() { atomic.AddInt64(&c, 1) })
+ d(func() { atomic.AddInt64(&c, 1) })
+ d(func() { atomic.AddInt64(&c, 1) })
+ time.Sleep(100 * time.Millisecond)
+ assert.EqualValues(t, 2, atomic.LoadInt64(&c))
+}
diff --git a/modules/util/truncate.go b/modules/util/truncate.go
new file mode 100644
index 0000000..77b116e
--- /dev/null
+++ b/modules/util/truncate.go
@@ -0,0 +1,54 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "strings"
+ "unicode/utf8"
+)
+
+// in UTF8 "…" is 3 bytes so doesn't really gain us anything...
+const (
+ utf8Ellipsis = "…"
+ asciiEllipsis = "..."
+)
+
+// SplitStringAtByteN splits a string at byte n accounting for rune boundaries. (Combining characters are not accounted for.)
+func SplitStringAtByteN(input string, n int) (left, right string) {
+ if len(input) <= n {
+ return input, ""
+ }
+
+ if !utf8.ValidString(input) {
+ if n-3 < 0 {
+ return input, ""
+ }
+ return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:]
+ }
+
+ end := 0
+ for end <= n-3 {
+ _, size := utf8.DecodeRuneInString(input[end:])
+ if end+size > n-3 {
+ break
+ }
+ end += size
+ }
+
+ return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
+}
+
+// SplitTrimSpace splits the string at given separator and trims leading and trailing space
+func SplitTrimSpace(input, sep string) []string {
+ // replace CRLF with LF
+ input = strings.ReplaceAll(input, "\r\n", "\n")
+
+ var stringList []string
+ for _, s := range strings.Split(input, sep) {
+ // trim leading and trailing space
+ stringList = append(stringList, strings.TrimSpace(s))
+ }
+
+ return stringList
+}
diff --git a/modules/util/truncate_test.go b/modules/util/truncate_test.go
new file mode 100644
index 0000000..dfe1230
--- /dev/null
+++ b/modules/util/truncate_test.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSplitString(t *testing.T) {
+ type testCase struct {
+ input string
+ n int
+ leftSub string
+ ellipsis string
+ }
+
+ test := func(tc []*testCase, f func(input string, n int) (left, right string)) {
+ for _, c := range tc {
+ l, r := f(c.input, c.n)
+ if c.ellipsis != "" {
+ assert.Equal(t, c.leftSub+c.ellipsis, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub)
+ assert.Equal(t, c.ellipsis+c.input[len(c.leftSub):], r, "test split %s at %d, expected rightSub: %q", c.input, c.n, c.input[len(c.leftSub):])
+ } else {
+ assert.Equal(t, c.leftSub, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub)
+ assert.Empty(t, r, "test split %q at %d, expected rightSub: %q", c.input, c.n, "")
+ }
+ }
+ }
+
+ tc := []*testCase{
+ {"abc123xyz", 0, "", utf8Ellipsis},
+ {"abc123xyz", 1, "", utf8Ellipsis},
+ {"abc123xyz", 4, "a", utf8Ellipsis},
+ {"å•Šbc123xyz", 4, "", utf8Ellipsis},
+ {"å•Šbc123xyz", 6, "å•Š", utf8Ellipsis},
+ {"å•Šbc", 5, "å•Šbc", ""},
+ {"å•Šbc", 6, "å•Šbc", ""},
+ {"abc\xef\x03\xfe", 3, "", asciiEllipsis},
+ {"abc\xef\x03\xfe", 4, "a", asciiEllipsis},
+ {"\xef\x03", 1, "\xef\x03", ""},
+ }
+ test(tc, SplitStringAtByteN)
+}
diff --git a/modules/util/url.go b/modules/util/url.go
new file mode 100644
index 0000000..6237033
--- /dev/null
+++ b/modules/util/url.go
@@ -0,0 +1,50 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "net/url"
+ "path"
+ "strings"
+)
+
+// PathEscapeSegments escapes segments of a path while not escaping forward slash
+func PathEscapeSegments(path string) string {
+ slice := strings.Split(path, "/")
+ for index := range slice {
+ slice[index] = url.PathEscape(slice[index])
+ }
+ escapedPath := strings.Join(slice, "/")
+ return escapedPath
+}
+
+// URLJoin joins url components, like path.Join, but preserving contents
+func URLJoin(base string, elems ...string) string {
+ if !strings.HasSuffix(base, "/") {
+ base += "/"
+ }
+ baseURL, err := url.Parse(base)
+ if err != nil {
+ return ""
+ }
+ joinedPath := path.Join(elems...)
+ argURL, err := url.Parse(joinedPath)
+ if err != nil {
+ return ""
+ }
+ joinedURL := baseURL.ResolveReference(argURL).String()
+ if !baseURL.IsAbs() && !strings.HasPrefix(base, "/") {
+ return joinedURL[1:] // Removing leading '/' if needed
+ }
+ return joinedURL
+}
+
+func SanitizeURL(s string) (string, error) {
+ u, err := url.Parse(s)
+ if err != nil {
+ return "", err
+ }
+ u.User = nil
+ return u.String(), nil
+}
diff --git a/modules/util/util.go b/modules/util/util.go
new file mode 100644
index 0000000..dcd7cf4
--- /dev/null
+++ b/modules/util/util.go
@@ -0,0 +1,264 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/optional"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+// OptionalBoolParse get the corresponding optional.Option[bool] of a string using strconv.ParseBool
+func OptionalBoolParse(s string) optional.Option[bool] {
+ v, e := strconv.ParseBool(s)
+ if e != nil {
+ return optional.None[bool]()
+ }
+ return optional.Some(v)
+}
+
+// IsEmptyString checks if the provided string is empty
+func IsEmptyString(s string) bool {
+ return len(strings.TrimSpace(s)) == 0
+}
+
+// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
+func NormalizeEOL(input []byte) []byte {
+ var right, left, pos int
+ if right = bytes.IndexByte(input, '\r'); right == -1 {
+ return input
+ }
+ length := len(input)
+ tmp := make([]byte, length)
+
+ // We know that left < length because otherwise right would be -1 from IndexByte.
+ copy(tmp[pos:pos+right], input[left:left+right])
+ pos += right
+ tmp[pos] = '\n'
+ left += right + 1
+ pos++
+
+ for left < length {
+ if input[left] == '\n' {
+ left++
+ }
+
+ right = bytes.IndexByte(input[left:], '\r')
+ if right == -1 {
+ copy(tmp[pos:], input[left:])
+ pos += length - left
+ break
+ }
+ copy(tmp[pos:pos+right], input[left:left+right])
+ pos += right
+ tmp[pos] = '\n'
+ left += right + 1
+ pos++
+ }
+ return tmp[:pos]
+}
+
+// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
+func CryptoRandomInt(limit int64) (int64, error) {
+ rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
+ if err != nil {
+ return 0, err
+ }
+ return rInt.Int64(), nil
+}
+
+const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range
+func CryptoRandomString(length int64) (string, error) {
+ buf := make([]byte, length)
+ limit := int64(len(alphanumericalChars))
+ for i := range buf {
+ num, err := CryptoRandomInt(limit)
+ if err != nil {
+ return "", err
+ }
+ buf[i] = alphanumericalChars[num]
+ }
+ return string(buf), nil
+}
+
+// CryptoRandomBytes generates `length` crypto bytes
+// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
+// This function generates totally random bytes, each byte is generated by [0,255] range
+func CryptoRandomBytes(length int64) ([]byte, error) {
+ buf := make([]byte, length)
+ _, err := rand.Read(buf)
+ return buf, err
+}
+
+// ToUpperASCII returns s with all ASCII letters mapped to their upper case.
+func ToUpperASCII(s string) string {
+ b := []byte(s)
+ for i, c := range b {
+ if 'a' <= c && c <= 'z' {
+ b[i] -= 'a' - 'A'
+ }
+ }
+ return string(b)
+}
+
+// ToTitleCase returns s with all english words capitalized
+func ToTitleCase(s string) string {
+ // `cases.Title` is not thread-safe, do not use global shared variable for it
+ return cases.Title(language.English).String(s)
+}
+
+// ToTitleCaseNoLower returns s with all english words capitalized without lower-casing
+func ToTitleCaseNoLower(s string) string {
+ // `cases.Title` is not thread-safe, do not use global shared variable for it
+ return cases.Title(language.English, cases.NoLower).String(s)
+}
+
+// ToInt64 transform a given int into int64.
+func ToInt64(number any) (int64, error) {
+ var value int64
+ switch v := number.(type) {
+ case int:
+ value = int64(v)
+ case int8:
+ value = int64(v)
+ case int16:
+ value = int64(v)
+ case int32:
+ value = int64(v)
+ case int64:
+ value = v
+
+ case uint:
+ value = int64(v)
+ case uint8:
+ value = int64(v)
+ case uint16:
+ value = int64(v)
+ case uint32:
+ value = int64(v)
+ case uint64:
+ value = int64(v)
+
+ case float32:
+ value = int64(v)
+ case float64:
+ value = int64(v)
+
+ case string:
+ var err error
+ if value, err = strconv.ParseInt(v, 10, 64); err != nil {
+ return 0, err
+ }
+ default:
+ return 0, fmt.Errorf("unable to convert %v to int64", number)
+ }
+ return value, nil
+}
+
+// ToFloat64 transform a given int into float64.
+func ToFloat64(number any) (float64, error) {
+ var value float64
+ switch v := number.(type) {
+ case int:
+ value = float64(v)
+ case int8:
+ value = float64(v)
+ case int16:
+ value = float64(v)
+ case int32:
+ value = float64(v)
+ case int64:
+ value = float64(v)
+
+ case uint:
+ value = float64(v)
+ case uint8:
+ value = float64(v)
+ case uint16:
+ value = float64(v)
+ case uint32:
+ value = float64(v)
+ case uint64:
+ value = float64(v)
+
+ case float32:
+ value = float64(v)
+ case float64:
+ value = v
+
+ case string:
+ var err error
+ if value, err = strconv.ParseFloat(v, 64); err != nil {
+ return 0, err
+ }
+ default:
+ return 0, fmt.Errorf("unable to convert %v to float64", number)
+ }
+ return value, nil
+}
+
+// ToPointer returns the pointer of a copy of any given value
+func ToPointer[T any](val T) *T {
+ return &val
+}
+
+// Iif is an "inline-if", it returns "trueVal" if "condition" is true, otherwise "falseVal"
+func Iif[T any](condition bool, trueVal, falseVal T) T {
+ if condition {
+ return trueVal
+ }
+ return falseVal
+}
+
+// IfZero returns "def" if "v" is a zero value, otherwise "v"
+func IfZero[T comparable](v, def T) T {
+ var zero T
+ if v == zero {
+ return def
+ }
+ return v
+}
+
+func ReserveLineBreakForTextarea(input string) string {
+ // Since the content is from a form which is a textarea, the line endings are \r\n.
+ // It's a standard behavior of HTML.
+ // But we want to store them as \n like what GitHub does.
+ // And users are unlikely to really need to keep the \r.
+ // Other than this, we should respect the original content, even leading or trailing spaces.
+ return strings.ReplaceAll(input, "\r\n", "\n")
+}
+
+// GenerateSSHKeypair generates a ed25519 SSH-compatible keypair.
+func GenerateSSHKeypair() (publicKey, privateKey []byte, err error) {
+ public, private, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ return nil, nil, fmt.Errorf("ed25519.GenerateKey: %w", err)
+ }
+
+ privPEM, err := ssh.MarshalPrivateKey(private, "")
+ if err != nil {
+ return nil, nil, fmt.Errorf("ssh.MarshalPrivateKey: %w", err)
+ }
+
+ sshPublicKey, err := ssh.NewPublicKey(public)
+ if err != nil {
+ return nil, nil, fmt.Errorf("ssh.NewPublicKey: %w", err)
+ }
+
+ return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil
+}
diff --git a/modules/util/util_test.go b/modules/util/util_test.go
new file mode 100644
index 0000000..549b53f
--- /dev/null
+++ b/modules/util/util_test.go
@@ -0,0 +1,277 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util_test
+
+import (
+ "bytes"
+ "crypto/rand"
+ "regexp"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestURLJoin(t *testing.T) {
+ type test struct {
+ Expected string
+ Base string
+ Elements []string
+ }
+ newTest := func(expected, base string, elements ...string) test {
+ return test{Expected: expected, Base: base, Elements: elements}
+ }
+ for _, test := range []test{
+ newTest("https://try.gitea.io/a/b/c",
+ "https://try.gitea.io", "a/b", "c"),
+ newTest("https://try.gitea.io/a/b/c",
+ "https://try.gitea.io/", "/a/b/", "/c/"),
+ newTest("https://try.gitea.io/a/c",
+ "https://try.gitea.io/", "/a/./b/", "../c/"),
+ newTest("a/b/c",
+ "a", "b/c/"),
+ newTest("a/b/d",
+ "a/", "b/c/", "/../d/"),
+ newTest("https://try.gitea.io/a/b/c#d",
+ "https://try.gitea.io", "a/b", "c#d"),
+ newTest("/a/b/d",
+ "/a/", "b/c/", "/../d/"),
+ newTest("/a/b/c",
+ "/a", "b/c/"),
+ newTest("/a/b/c#hash",
+ "/a", "b/c#hash"),
+ } {
+ assert.Equal(t, test.Expected, util.URLJoin(test.Base, test.Elements...))
+ }
+}
+
+func TestIsEmptyString(t *testing.T) {
+ cases := []struct {
+ s string
+ expected bool
+ }{
+ {"", true},
+ {" ", true},
+ {" ", true},
+ {" a", false},
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.expected, util.IsEmptyString(v.s))
+ }
+}
+
+func Test_NormalizeEOL(t *testing.T) {
+ data1 := []string{
+ "",
+ "This text starts with empty lines",
+ "another",
+ "",
+ "",
+ "",
+ "Some other empty lines in the middle",
+ "more.",
+ "And more.",
+ "Ends with empty lines too.",
+ "",
+ "",
+ "",
+ }
+
+ data2 := []string{
+ "This text does not start with empty lines",
+ "another",
+ "",
+ "",
+ "",
+ "Some other empty lines in the middle",
+ "more.",
+ "And more.",
+ "Ends without EOLtoo.",
+ }
+
+ buildEOLData := func(data []string, eol string) []byte {
+ return []byte(strings.Join(data, eol))
+ }
+
+ dos := buildEOLData(data1, "\r\n")
+ unix := buildEOLData(data1, "\n")
+ mac := buildEOLData(data1, "\r")
+
+ assert.Equal(t, unix, util.NormalizeEOL(dos))
+ assert.Equal(t, unix, util.NormalizeEOL(mac))
+ assert.Equal(t, unix, util.NormalizeEOL(unix))
+
+ dos = buildEOLData(data2, "\r\n")
+ unix = buildEOLData(data2, "\n")
+ mac = buildEOLData(data2, "\r")
+
+ assert.Equal(t, unix, util.NormalizeEOL(dos))
+ assert.Equal(t, unix, util.NormalizeEOL(mac))
+ assert.Equal(t, unix, util.NormalizeEOL(unix))
+
+ assert.Equal(t, []byte("one liner"), util.NormalizeEOL([]byte("one liner")))
+ assert.Equal(t, []byte("\n"), util.NormalizeEOL([]byte("\n")))
+ assert.Equal(t, []byte("\ntwo liner"), util.NormalizeEOL([]byte("\ntwo liner")))
+ assert.Equal(t, []byte("two liner\n"), util.NormalizeEOL([]byte("two liner\n")))
+ assert.Equal(t, []byte{}, util.NormalizeEOL([]byte{}))
+
+ assert.Equal(t, []byte("mix\nand\nmatch\n."), util.NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
+}
+
+func Test_RandomInt(t *testing.T) {
+ randInt, err := util.CryptoRandomInt(255)
+ assert.GreaterOrEqual(t, randInt, int64(0))
+ assert.LessOrEqual(t, randInt, int64(255))
+ require.NoError(t, err)
+}
+
+func Test_RandomString(t *testing.T) {
+ str1, err := util.CryptoRandomString(32)
+ require.NoError(t, err)
+ matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
+ require.NoError(t, err)
+ assert.True(t, matches)
+
+ str2, err := util.CryptoRandomString(32)
+ require.NoError(t, err)
+ matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
+ require.NoError(t, err)
+ assert.True(t, matches)
+
+ assert.NotEqual(t, str1, str2)
+
+ str3, err := util.CryptoRandomString(256)
+ require.NoError(t, err)
+ matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
+ require.NoError(t, err)
+ assert.True(t, matches)
+
+ str4, err := util.CryptoRandomString(256)
+ require.NoError(t, err)
+ matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
+ require.NoError(t, err)
+ assert.True(t, matches)
+
+ assert.NotEqual(t, str3, str4)
+}
+
+func Test_RandomBytes(t *testing.T) {
+ bytes1, err := util.CryptoRandomBytes(32)
+ require.NoError(t, err)
+
+ bytes2, err := util.CryptoRandomBytes(32)
+ require.NoError(t, err)
+
+ assert.NotEqual(t, bytes1, bytes2)
+
+ bytes3, err := util.CryptoRandomBytes(256)
+ require.NoError(t, err)
+
+ bytes4, err := util.CryptoRandomBytes(256)
+ require.NoError(t, err)
+
+ assert.NotEqual(t, bytes3, bytes4)
+}
+
+func TestOptionalBoolParse(t *testing.T) {
+ assert.Equal(t, optional.None[bool](), util.OptionalBoolParse(""))
+ assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("x"))
+
+ assert.Equal(t, optional.Some(false), util.OptionalBoolParse("0"))
+ assert.Equal(t, optional.Some(false), util.OptionalBoolParse("f"))
+ assert.Equal(t, optional.Some(false), util.OptionalBoolParse("False"))
+
+ assert.Equal(t, optional.Some(true), util.OptionalBoolParse("1"))
+ assert.Equal(t, optional.Some(true), util.OptionalBoolParse("t"))
+ assert.Equal(t, optional.Some(true), util.OptionalBoolParse("True"))
+}
+
+// Test case for any function which accepts and returns a single string.
+type StringTest struct {
+ in, out string
+}
+
+var upperTests = []StringTest{
+ {"", ""},
+ {"ONLYUPPER", "ONLYUPPER"},
+ {"abc", "ABC"},
+ {"AbC123", "ABC123"},
+ {"azAZ09_", "AZAZ09_"},
+ {"longStrinGwitHmixofsmaLLandcAps", "LONGSTRINGWITHMIXOFSMALLANDCAPS"},
+ {"long\u0250string\u0250with\u0250nonascii\u2C6Fchars", "LONG\u0250STRING\u0250WITH\u0250NONASCII\u2C6FCHARS"},
+ {"\u0250\u0250\u0250\u0250\u0250", "\u0250\u0250\u0250\u0250\u0250"},
+ {"a\u0080\U0010FFFF", "A\u0080\U0010FFFF"},
+ {"lél", "LéL"},
+}
+
+func TestToUpperASCII(t *testing.T) {
+ for _, tc := range upperTests {
+ assert.Equal(t, util.ToUpperASCII(tc.in), tc.out)
+ }
+}
+
+func BenchmarkToUpper(b *testing.B) {
+ for _, tc := range upperTests {
+ b.Run(tc.in, func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ util.ToUpperASCII(tc.in)
+ }
+ })
+ }
+}
+
+func TestToTitleCase(t *testing.T) {
+ assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`foo bar baz`))
+ assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`FOO BAR BAZ`))
+}
+
+func TestToPointer(t *testing.T) {
+ assert.Equal(t, "abc", *util.ToPointer("abc"))
+ assert.Equal(t, 123, *util.ToPointer(123))
+ abc := "abc"
+ assert.NotSame(t, &abc, util.ToPointer(abc))
+ val123 := 123
+ assert.NotSame(t, &val123, util.ToPointer(val123))
+}
+
+func TestReserveLineBreakForTextarea(t *testing.T) {
+ assert.Equal(t, "test\ndata", util.ReserveLineBreakForTextarea("test\r\ndata"))
+ assert.Equal(t, "test\ndata\n", util.ReserveLineBreakForTextarea("test\r\ndata\r\n"))
+}
+
+const (
+ testPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4\n"
+ testPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
+c2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TWMJulDV8d3IZkElUxuAAA
+AIggISIjICEiIwAAAAtzc2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TW
+MJulDV8d3IZkElUxuAAAAEAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0e
+HwOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4AAAAAAECAwQF
+-----END OPENSSH PRIVATE KEY-----` + "\n"
+)
+
+func TestGeneratingEd25519Keypair(t *testing.T) {
+ defer test.MockProtect(&rand.Reader)()
+
+ // Only 32 bytes needs to be provided to generate a ed25519 keypair.
+ // And another 32 bytes are required, which is included as random value
+ // in the OpenSSH format.
+ b := make([]byte, 64)
+ for i := 0; i < 64; i++ {
+ b[i] = byte(i)
+ }
+ rand.Reader = bytes.NewReader(b)
+
+ publicKey, privateKey, err := util.GenerateSSHKeypair()
+ require.NoError(t, err)
+ assert.EqualValues(t, testPublicKey, string(publicKey))
+ assert.EqualValues(t, testPrivateKey, string(privateKey))
+}
diff --git a/modules/validation/binding.go b/modules/validation/binding.go
new file mode 100644
index 0000000..cb0a506
--- /dev/null
+++ b/modules/validation/binding.go
@@ -0,0 +1,209 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/auth"
+ "code.gitea.io/gitea/modules/git"
+
+ "gitea.com/go-chi/binding"
+ "github.com/gobwas/glob"
+)
+
+const (
+ // ErrGitRefName is git reference name error
+ ErrGitRefName = "GitRefNameError"
+ // ErrGlobPattern is returned when glob pattern is invalid
+ ErrGlobPattern = "GlobPattern"
+ // ErrRegexPattern is returned when a regex pattern is invalid
+ ErrRegexPattern = "RegexPattern"
+ // ErrUsername is username error
+ ErrUsername = "UsernameError"
+ // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
+ ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
+)
+
+// AddBindingRules adds additional binding rules
+func AddBindingRules() {
+ addGitRefNameBindingRule()
+ addValidURLBindingRule()
+ addValidSiteURLBindingRule()
+ addGlobPatternRule()
+ addRegexPatternRule()
+ addGlobOrRegexPatternRule()
+ addUsernamePatternRule()
+ addValidGroupTeamMapRule()
+}
+
+func addGitRefNameBindingRule() {
+ // Git refname validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "GitRefName")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if !git.IsValidRefPattern(str) {
+ errs.Add([]string{name}, ErrGitRefName, "GitRefName")
+ return false, errs
+ }
+ return true, errs
+ },
+ })
+}
+
+func addValidURLBindingRule() {
+ // URL validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidUrl")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if len(str) != 0 && !IsValidURL(str) {
+ errs.Add([]string{name}, binding.ERR_URL, "Url")
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func addValidSiteURLBindingRule() {
+ // URL validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidSiteUrl")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if len(str) != 0 && !IsValidSiteURL(str) {
+ errs.Add([]string{name}, binding.ERR_URL, "Url")
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func addGlobPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "GlobPattern"
+ },
+ IsValid: globPatternValidator,
+ })
+}
+
+func globPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if len(str) != 0 {
+ if _, err := glob.Compile(str); err != nil {
+ errs.Add([]string{name}, ErrGlobPattern, err.Error())
+ return false, errs
+ }
+ }
+
+ return true, errs
+}
+
+func addRegexPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "RegexPattern"
+ },
+ IsValid: regexPatternValidator,
+ })
+}
+
+func regexPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if _, err := regexp.Compile(str); err != nil {
+ errs.Add([]string{name}, ErrRegexPattern, err.Error())
+ return false, errs
+ }
+
+ return true, errs
+}
+
+func addGlobOrRegexPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "GlobOrRegexPattern"
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := strings.TrimSpace(fmt.Sprintf("%v", val))
+
+ if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") {
+ return regexPatternValidator(errs, name, str[1:len(str)-1])
+ }
+ return globPatternValidator(errs, name, val)
+ },
+ })
+}
+
+func addUsernamePatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "Username"
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if !IsValidUsername(str) {
+ errs.Add([]string{name}, ErrUsername, "invalid username")
+ return false, errs
+ }
+ return true, errs
+ },
+ })
+}
+
+func addValidGroupTeamMapRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidGroupTeamMap")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))
+ if err != nil {
+ errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error())
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func portOnly(hostport string) string {
+ colon := strings.IndexByte(hostport, ':')
+ if colon == -1 {
+ return ""
+ }
+ if i := strings.Index(hostport, "]:"); i != -1 {
+ return hostport[i+len("]:"):]
+ }
+ if strings.Contains(hostport, "]") {
+ return ""
+ }
+ return hostport[colon+len(":"):]
+}
+
+func validPort(p string) bool {
+ for _, r := range []byte(p) {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/modules/validation/binding_test.go b/modules/validation/binding_test.go
new file mode 100644
index 0000000..01ff4e3
--- /dev/null
+++ b/modules/validation/binding_test.go
@@ -0,0 +1,62 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gitea.com/go-chi/binding"
+ chi "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ testRoute = "/test"
+)
+
+type (
+ validationTestCase struct {
+ description string
+ data any
+ expectedErrors binding.Errors
+ }
+
+ TestForm struct {
+ BranchName string `form:"BranchName" binding:"GitRefName"`
+ URL string `form:"ValidUrl" binding:"ValidUrl"`
+ GlobPattern string `form:"GlobPattern" binding:"GlobPattern"`
+ RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
+ }
+)
+
+func performValidationTest(t *testing.T, testCase validationTestCase) {
+ httpRecorder := httptest.NewRecorder()
+ m := chi.NewRouter()
+
+ m.Post(testRoute, func(resp http.ResponseWriter, req *http.Request) {
+ actual := binding.Validate(req, testCase.data)
+ // see https://github.com/stretchr/testify/issues/435
+ if actual == nil {
+ actual = binding.Errors{}
+ }
+
+ assert.Equal(t, testCase.expectedErrors, actual)
+ })
+
+ req, err := http.NewRequest("POST", testRoute, nil)
+ if err != nil {
+ panic(err)
+ }
+ req.Header.Add("Content-Type", "x-www-form-urlencoded")
+ m.ServeHTTP(httpRecorder, req)
+
+ switch httpRecorder.Code {
+ case http.StatusNotFound:
+ panic("Routing is messed up in test fixture (got 404): check methods and paths")
+ case http.StatusInternalServerError:
+ panic("Something bad happened on '" + testCase.description + "'")
+ }
+}
diff --git a/modules/validation/glob_pattern_test.go b/modules/validation/glob_pattern_test.go
new file mode 100644
index 0000000..1bf622e
--- /dev/null
+++ b/modules/validation/glob_pattern_test.go
@@ -0,0 +1,61 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+ "github.com/gobwas/glob"
+)
+
+func getGlobPatternErrorString(pattern string) string {
+ // It would be unwise to rely on that glob
+ // compilation errors don't ever change.
+ if _, err := glob.Compile(pattern); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+var globValidationTestCases = []validationTestCase{
+ {
+ description: "Empty glob pattern",
+ data: TestForm{
+ GlobPattern: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Valid glob",
+ data: TestForm{
+ GlobPattern: "{master,release*}",
+ },
+ expectedErrors: binding.Errors{},
+ },
+
+ {
+ description: "Invalid glob",
+ data: TestForm{
+ GlobPattern: "[a-",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"GlobPattern"},
+ Classification: ErrGlobPattern,
+ Message: getGlobPatternErrorString("[a-"),
+ },
+ },
+ },
+}
+
+func Test_GlobPatternValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range globValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go
new file mode 100644
index 0000000..567ad86
--- /dev/null
+++ b/modules/validation/helpers.go
@@ -0,0 +1,136 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "net"
+ "net/url"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/gobwas/glob"
+)
+
+var externalTrackerRegex = regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`)
+
+func isLoopbackIP(ip string) bool {
+ return net.ParseIP(ip).IsLoopback()
+}
+
+// IsValidURL checks if URL is valid
+func IsValidURL(uri string) bool {
+ if u, err := url.ParseRequestURI(uri); err != nil ||
+ (u.Scheme != "http" && u.Scheme != "https") ||
+ !validPort(portOnly(u.Host)) {
+ return false
+ }
+
+ return true
+}
+
+// IsValidSiteURL checks if URL is valid
+func IsValidSiteURL(uri string) bool {
+ u, err := url.ParseRequestURI(uri)
+ if err != nil {
+ return false
+ }
+
+ if !validPort(portOnly(u.Host)) {
+ return false
+ }
+
+ for _, scheme := range setting.Service.ValidSiteURLSchemes {
+ if scheme == u.Scheme {
+ return true
+ }
+ }
+ return false
+}
+
+// IsEmailDomainListed checks whether the domain of an email address
+// matches a list of domains
+func IsEmailDomainListed(globs []glob.Glob, email string) bool {
+ if len(globs) == 0 {
+ return false
+ }
+
+ n := strings.LastIndex(email, "@")
+ if n <= 0 {
+ return false
+ }
+
+ domain := strings.ToLower(email[n+1:])
+
+ for _, g := range globs {
+ if g.Match(domain) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// IsAPIURL checks if URL is current Gitea instance API URL
+func IsAPIURL(uri string) bool {
+ return strings.HasPrefix(strings.ToLower(uri), strings.ToLower(setting.AppURL+"api"))
+}
+
+// IsValidExternalURL checks if URL is valid external URL
+func IsValidExternalURL(uri string) bool {
+ if !IsValidURL(uri) || IsAPIURL(uri) {
+ return false
+ }
+
+ u, err := url.ParseRequestURI(uri)
+ if err != nil {
+ return false
+ }
+
+ // Currently check only if not loopback IP is provided to keep compatibility
+ if isLoopbackIP(u.Hostname()) || strings.ToLower(u.Hostname()) == "localhost" {
+ return false
+ }
+
+ // TODO: Later it should be added to allow local network IP addresses
+ // only if allowed by special setting
+
+ return true
+}
+
+// IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers
+func IsValidExternalTrackerURLFormat(uri string) bool {
+ if !IsValidExternalURL(uri) {
+ return false
+ }
+
+ // check for typoed variables like /{index/ or /[repo}
+ for _, match := range externalTrackerRegex.FindAllStringSubmatch(uri, -1) {
+ if (match[1] == "{" || match[2] == "}") && (match[1] != "{" || match[2] != "}") {
+ return false
+ }
+ }
+
+ return true
+}
+
+var (
+ validUsernamePatternWithDots = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`)
+ validUsernamePatternWithoutDots = regexp.MustCompile(`^[\da-zA-Z][-\w]*$`)
+
+ // No consecutive or trailing non-alphanumeric chars, catches both cases
+ invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`)
+)
+
+// IsValidUsername checks if username is valid
+func IsValidUsername(name string) bool {
+ // It is difficult to find a single pattern that is both readable and effective,
+ // but it's easier to use positive and negative checks.
+ if setting.Service.AllowDotsInUsernames {
+ return validUsernamePatternWithDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
+ }
+
+ return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
+}
diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go
new file mode 100644
index 0000000..a1bdf2a
--- /dev/null
+++ b/modules/validation/helpers_test.go
@@ -0,0 +1,216 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_IsValidURL(t *testing.T) {
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Empty URL",
+ url: "",
+ valid: false,
+ },
+ {
+ description: "Loopback IPv4 URL",
+ url: "http://127.0.1.1:5678/",
+ valid: true,
+ },
+ {
+ description: "Loopback IPv6 URL",
+ url: "https://[::1]/",
+ valid: true,
+ },
+ {
+ description: "Missing semicolon after schema",
+ url: "http//meh/",
+ valid: false,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidURL(testCase.url))
+ })
+ }
+}
+
+func Test_IsValidExternalURL(t *testing.T) {
+ setting.AppURL = "https://try.gitea.io/"
+
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Current instance URL",
+ url: "https://try.gitea.io/test",
+ valid: true,
+ },
+ {
+ description: "Loopback IPv4 URL",
+ url: "http://127.0.1.1:5678/",
+ valid: false,
+ },
+ {
+ description: "Current instance API URL",
+ url: "https://try.gitea.io/api/v1/user/follow",
+ valid: false,
+ },
+ {
+ description: "Local network URL",
+ url: "http://192.168.1.2/api/v1/user/follow",
+ valid: true,
+ },
+ {
+ description: "Local URL",
+ url: "http://LOCALHOST:1234/whatever",
+ valid: false,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidExternalURL(testCase.url))
+ })
+ }
+}
+
+func Test_IsValidExternalTrackerURLFormat(t *testing.T) {
+ setting.AppURL = "https://try.gitea.io/"
+
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Correct external tracker URL with all placeholders",
+ url: "https://github.com/{user}/{repo}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "Local external tracker URL with all placeholders",
+ url: "https://127.0.0.1/{user}/{repo}/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/{user}/{repo/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/[user}/{repo/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/{user}/repo}/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/{user}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/{repo}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/issues/{user}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL with similar placeholder names test",
+ url: "https://github.com/user/repo/issues/{index}",
+ valid: true,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidExternalTrackerURLFormat(testCase.url))
+ })
+ }
+}
+
+func TestIsValidUsernameAllowDots(t *testing.T) {
+ setting.Service.AllowDotsInUsernames = true
+ tests := []struct {
+ arg string
+ want bool
+ }{
+ {arg: "a", want: true},
+ {arg: "abc", want: true},
+ {arg: "0.b-c", want: true},
+ {arg: "a.b-c_d", want: true},
+ {arg: "", want: false},
+ {arg: ".abc", want: false},
+ {arg: "abc.", want: false},
+ {arg: "a..bc", want: false},
+ {arg: "a...bc", want: false},
+ {arg: "a.-bc", want: false},
+ {arg: "a._bc", want: false},
+ {arg: "a_-bc", want: false},
+ {arg: "a/bc", want: false},
+ {arg: "â˜ï¸", want: false},
+ {arg: "-", want: false},
+ {arg: "--diff", want: false},
+ {arg: "-im-here", want: false},
+ {arg: "a space", want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.arg, func(t *testing.T) {
+ assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername(%v)", tt.arg)
+ })
+ }
+}
+
+func TestIsValidUsernameBanDots(t *testing.T) {
+ setting.Service.AllowDotsInUsernames = false
+ defer func() {
+ setting.Service.AllowDotsInUsernames = true
+ }()
+
+ tests := []struct {
+ arg string
+ want bool
+ }{
+ {arg: "a", want: true},
+ {arg: "abc", want: true},
+ {arg: "0.b-c", want: false},
+ {arg: "a.b-c_d", want: false},
+ {arg: ".abc", want: false},
+ {arg: "abc.", want: false},
+ {arg: "a..bc", want: false},
+ {arg: "a...bc", want: false},
+ {arg: "a.-bc", want: false},
+ {arg: "a._bc", want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.arg, func(t *testing.T) {
+ assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername[AllowDotsInUsernames=false](%v)", tt.arg)
+ })
+ }
+}
diff --git a/modules/validation/refname_test.go b/modules/validation/refname_test.go
new file mode 100644
index 0000000..3af7387
--- /dev/null
+++ b/modules/validation/refname_test.go
@@ -0,0 +1,265 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+var gitRefNameValidationTestCases = []validationTestCase{
+ {
+ description: "Reference name contains only characters",
+ data: TestForm{
+ BranchName: "test",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name contains single slash",
+ data: TestForm{
+ BranchName: "feature/test",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name has allowed special characters",
+ data: TestForm{
+ BranchName: "debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name contains backslash",
+ data: TestForm{
+ BranchName: "feature\\test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name starts with dot",
+ data: TestForm{
+ BranchName: ".test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with dot",
+ data: TestForm{
+ BranchName: "test.",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name starts with slash",
+ data: TestForm{
+ BranchName: "/test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with slash",
+ data: TestForm{
+ BranchName: "test/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with .lock",
+ data: TestForm{
+ BranchName: "test.lock",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name contains multiple consecutive dots",
+ data: TestForm{
+ BranchName: "te..st",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name contains multiple consecutive slashes",
+ data: TestForm{
+ BranchName: "te//st",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name is single @",
+ data: TestForm{
+ BranchName: "@",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has @{",
+ data: TestForm{
+ BranchName: "branch@{",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ~",
+ data: TestForm{
+ BranchName: "~debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character *",
+ data: TestForm{
+ BranchName: "*debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ?",
+ data: TestForm{
+ BranchName: "?debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ^",
+ data: TestForm{
+ BranchName: "^debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character :",
+ data: TestForm{
+ BranchName: "debian:jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character (whitespace)",
+ data: TestForm{
+ BranchName: "debian jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character [",
+ data: TestForm{
+ BranchName: "debian[jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+}
+
+func Test_GitRefNameValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range gitRefNameValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/regex_pattern_test.go b/modules/validation/regex_pattern_test.go
new file mode 100644
index 0000000..efcb276
--- /dev/null
+++ b/modules/validation/regex_pattern_test.go
@@ -0,0 +1,59 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "regexp"
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+func getRegexPatternErrorString(pattern string) string {
+ if _, err := regexp.Compile(pattern); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+var regexValidationTestCases = []validationTestCase{
+ {
+ description: "Empty regex pattern",
+ data: TestForm{
+ RegexPattern: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Valid regex",
+ data: TestForm{
+ RegexPattern: `(\d{1,3})+`,
+ },
+ expectedErrors: binding.Errors{},
+ },
+
+ {
+ description: "Invalid regex",
+ data: TestForm{
+ RegexPattern: "[a-",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"RegexPattern"},
+ Classification: ErrRegexPattern,
+ Message: getRegexPatternErrorString("[a-"),
+ },
+ },
+ },
+}
+
+func Test_RegexPatternValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range regexValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go
new file mode 100644
index 0000000..94b5cc1
--- /dev/null
+++ b/modules/validation/validatable.go
@@ -0,0 +1,84 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// ErrNotValid represents an validation error
+type ErrNotValid struct {
+ Message string
+}
+
+func (err ErrNotValid) Error() string {
+ return fmt.Sprintf("Validation Error: %v", err.Message)
+}
+
+// IsErrNotValid checks if an error is a ErrNotValid.
+func IsErrNotValid(err error) bool {
+ _, ok := err.(ErrNotValid)
+ return ok
+}
+
+type Validateable interface {
+ Validate() []string
+}
+
+func IsValid(v Validateable) (bool, error) {
+ if err := v.Validate(); len(err) > 0 {
+ typeof := reflect.TypeOf(v)
+ errString := strings.Join(err, "\n")
+ return false, ErrNotValid{fmt.Sprint(typeof, ": ", errString)}
+ }
+
+ return true, nil
+}
+
+func ValidateNotEmpty(value any, name string) []string {
+ isValid := true
+ switch v := value.(type) {
+ case string:
+ if v == "" {
+ isValid = false
+ }
+ case timeutil.TimeStamp:
+ if v.IsZero() {
+ isValid = false
+ }
+ case int64:
+ if v == 0 {
+ isValid = false
+ }
+ default:
+ isValid = false
+ }
+
+ if isValid {
+ return []string{}
+ }
+ return []string{fmt.Sprintf("%v should not be empty", name)}
+}
+
+func ValidateMaxLen(value string, maxLen int, name string) []string {
+ if utf8.RuneCountInString(value) > maxLen {
+ return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)}
+ }
+ return []string{}
+}
+
+func ValidateOneOf(value any, allowed []any, name string) []string {
+ for _, allowedElem := range allowed {
+ if value == allowedElem {
+ return []string{}
+ }
+ }
+ return []string{fmt.Sprintf("Value %v is not contained in allowed values %v", value, allowed)}
+}
diff --git a/modules/validation/validatable_test.go b/modules/validation/validatable_test.go
new file mode 100644
index 0000000..919f5a3
--- /dev/null
+++ b/modules/validation/validatable_test.go
@@ -0,0 +1,69 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+type Sut struct {
+ valid bool
+}
+
+func (sut Sut) Validate() []string {
+ if sut.valid {
+ return []string{}
+ }
+ return []string{"invalid"}
+}
+
+func Test_IsValid(t *testing.T) {
+ sut := Sut{valid: true}
+ if res, _ := IsValid(sut); !res {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+ sut = Sut{valid: false}
+ res, err := IsValid(sut)
+ if res {
+ t.Errorf("sut expected to be invalid: %v\n", sut.Validate())
+ }
+ if err == nil || !IsErrNotValid(err) || err.Error() != "Validation Error: validation.Sut: invalid" {
+ t.Errorf("validation error expected, but was %v", err)
+ }
+}
+
+func Test_ValidateNotEmpty_ForString(t *testing.T) {
+ sut := ""
+ if len(ValidateNotEmpty(sut, "dummyField")) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = "not empty"
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
+
+func Test_ValidateNotEmpty_ForTimestamp(t *testing.T) {
+ sut := timeutil.TimeStamp(0)
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = timeutil.TimeStampNow()
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
+
+func Test_ValidateMaxLen(t *testing.T) {
+ sut := "0123456789"
+ if len(ValidateMaxLen(sut, 9, "dummyField")) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = "0123456789"
+ if res := ValidateMaxLen(sut, 11, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
diff --git a/modules/validation/validurl_test.go b/modules/validation/validurl_test.go
new file mode 100644
index 0000000..39f7fa5
--- /dev/null
+++ b/modules/validation/validurl_test.go
@@ -0,0 +1,110 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+var urlValidationTestCases = []validationTestCase{
+ {
+ description: "Empty URL",
+ data: TestForm{
+ URL: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL without port",
+ data: TestForm{
+ URL: "http://test.lan/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with port",
+ data: TestForm{
+ URL: "http://test.lan:3000/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with IPv6 address without port",
+ data: TestForm{
+ URL: "http://[::1]/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with IPv6 address with port",
+ data: TestForm{
+ URL: "http://[::1]:3000/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Invalid URL",
+ data: TestForm{
+ URL: "http//test.lan/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid schema",
+ data: TestForm{
+ URL: "ftp://test.lan/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid port",
+ data: TestForm{
+ URL: "http://test.lan:3x4/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid port with IPv6 address",
+ data: TestForm{
+ URL: "http://[::1]:3x4/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+}
+
+func Test_ValidURLValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range urlValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/web/handler.go b/modules/web/handler.go
new file mode 100644
index 0000000..728cc5a
--- /dev/null
+++ b/modules/web/handler.go
@@ -0,0 +1,193 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+ goctx "context"
+ "fmt"
+ "net/http"
+ "reflect"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/web/routing"
+ "code.gitea.io/gitea/modules/web/types"
+)
+
+var responseStatusProviders = map[reflect.Type]func(req *http.Request) types.ResponseStatusProvider{}
+
+func RegisterResponseStatusProvider[T any](fn func(req *http.Request) types.ResponseStatusProvider) {
+ responseStatusProviders[reflect.TypeOf((*T)(nil)).Elem()] = fn
+}
+
+// responseWriter is a wrapper of http.ResponseWriter, to check whether the response has been written
+type responseWriter struct {
+ respWriter http.ResponseWriter
+ status int
+}
+
+var _ types.ResponseStatusProvider = (*responseWriter)(nil)
+
+func (r *responseWriter) WrittenStatus() int {
+ return r.status
+}
+
+func (r *responseWriter) Header() http.Header {
+ return r.respWriter.Header()
+}
+
+func (r *responseWriter) Write(bytes []byte) (int, error) {
+ if r.status == 0 {
+ r.status = http.StatusOK
+ }
+ return r.respWriter.Write(bytes)
+}
+
+func (r *responseWriter) WriteHeader(statusCode int) {
+ r.status = statusCode
+ r.respWriter.WriteHeader(statusCode)
+}
+
+var (
+ httpReqType = reflect.TypeOf((*http.Request)(nil))
+ respWriterType = reflect.TypeOf((*http.ResponseWriter)(nil)).Elem()
+ cancelFuncType = reflect.TypeOf((*goctx.CancelFunc)(nil)).Elem()
+)
+
+// preCheckHandler checks whether the handler is valid, developers could get first-time feedback, all mistakes could be found at startup
+func preCheckHandler(fn reflect.Value, argsIn []reflect.Value) {
+ hasStatusProvider := false
+ for _, argIn := range argsIn {
+ if _, hasStatusProvider = argIn.Interface().(types.ResponseStatusProvider); hasStatusProvider {
+ break
+ }
+ }
+ if !hasStatusProvider {
+ panic(fmt.Sprintf("handler should have at least one ResponseStatusProvider argument, but got %s", fn.Type()))
+ }
+ if fn.Type().NumOut() != 0 && fn.Type().NumIn() != 1 {
+ panic(fmt.Sprintf("handler should have no return value or only one argument, but got %s", fn.Type()))
+ }
+ if fn.Type().NumOut() == 1 && fn.Type().Out(0) != cancelFuncType {
+ panic(fmt.Sprintf("handler should return a cancel function, but got %s", fn.Type()))
+ }
+}
+
+func prepareHandleArgsIn(resp http.ResponseWriter, req *http.Request, fn reflect.Value, fnInfo *routing.FuncInfo) []reflect.Value {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error("unable to prepare handler arguments for %s: %v", fnInfo.String(), err)
+ panic(err)
+ }
+ }()
+ isPreCheck := req == nil
+
+ argsIn := make([]reflect.Value, fn.Type().NumIn())
+ for i := 0; i < fn.Type().NumIn(); i++ {
+ argTyp := fn.Type().In(i)
+ switch argTyp {
+ case respWriterType:
+ argsIn[i] = reflect.ValueOf(resp)
+ case httpReqType:
+ argsIn[i] = reflect.ValueOf(req)
+ default:
+ if argFn, ok := responseStatusProviders[argTyp]; ok {
+ if isPreCheck {
+ argsIn[i] = reflect.ValueOf(&responseWriter{})
+ } else {
+ argsIn[i] = reflect.ValueOf(argFn(req))
+ }
+ } else {
+ panic(fmt.Sprintf("unsupported argument type: %s", argTyp))
+ }
+ }
+ }
+ return argsIn
+}
+
+func handleResponse(fn reflect.Value, ret []reflect.Value) goctx.CancelFunc {
+ if len(ret) == 1 {
+ if cancelFunc, ok := ret[0].Interface().(goctx.CancelFunc); ok {
+ return cancelFunc
+ }
+ panic(fmt.Sprintf("unsupported return type: %s", ret[0].Type()))
+ } else if len(ret) > 1 {
+ panic(fmt.Sprintf("unsupported return values: %s", fn.Type()))
+ }
+ return nil
+}
+
+func hasResponseBeenWritten(argsIn []reflect.Value) bool {
+ for _, argIn := range argsIn {
+ if statusProvider, ok := argIn.Interface().(types.ResponseStatusProvider); ok {
+ if statusProvider.WrittenStatus() != 0 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// toHandlerProvider converts a handler to a handler provider
+// A handler provider is a function that takes a "next" http.Handler, it can be used as a middleware
+func toHandlerProvider(handler any) func(next http.Handler) http.Handler {
+ funcInfo := routing.GetFuncInfo(handler)
+ fn := reflect.ValueOf(handler)
+ if fn.Type().Kind() != reflect.Func {
+ panic(fmt.Sprintf("handler must be a function, but got %s", fn.Type()))
+ }
+
+ if hp, ok := handler.(func(next http.Handler) http.Handler); ok {
+ return func(next http.Handler) http.Handler {
+ h := hp(next) // this handle could be dynamically generated, so we can't use it for debug info
+ return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
+ routing.UpdateFuncInfo(req.Context(), funcInfo)
+ h.ServeHTTP(resp, req)
+ })
+ }
+ }
+
+ if hp, ok := handler.(func(next http.Handler) http.HandlerFunc); ok {
+ return func(next http.Handler) http.Handler {
+ h := hp(next) // this handle could be dynamically generated, so we can't use it for debug info
+ return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
+ routing.UpdateFuncInfo(req.Context(), funcInfo)
+ h.ServeHTTP(resp, req)
+ })
+ }
+ }
+
+ provider := func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
+ // wrap the response writer to check whether the response has been written
+ resp := respOrig
+ if _, ok := resp.(types.ResponseStatusProvider); !ok {
+ resp = &responseWriter{respWriter: resp}
+ }
+
+ // prepare the arguments for the handler and do pre-check
+ argsIn := prepareHandleArgsIn(resp, req, fn, funcInfo)
+ if req == nil {
+ preCheckHandler(fn, argsIn)
+ return // it's doing pre-check, just return
+ }
+
+ routing.UpdateFuncInfo(req.Context(), funcInfo)
+ ret := fn.Call(argsIn)
+
+ // handle the return value, and defer the cancel function if there is one
+ cancelFunc := handleResponse(fn, ret)
+ if cancelFunc != nil {
+ defer cancelFunc()
+ }
+
+ // if the response has not been written, call the next handler
+ if next != nil && !hasResponseBeenWritten(argsIn) {
+ next.ServeHTTP(resp, req)
+ }
+ })
+ }
+
+ provider(nil).ServeHTTP(nil, nil) // do a pre-check to make sure all arguments and return values are supported
+ return provider
+}
diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go
new file mode 100644
index 0000000..8fa71a8
--- /dev/null
+++ b/modules/web/middleware/binding.go
@@ -0,0 +1,162 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "reflect"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+
+ "gitea.com/go-chi/binding"
+)
+
+// Form form binding interface
+type Form interface {
+ binding.Validator
+}
+
+func init() {
+ binding.SetNameMapper(util.ToSnakeCase)
+}
+
+// AssignForm assign form values back to the template data.
+func AssignForm(form any, data map[string]any) {
+ typ := reflect.TypeOf(form)
+ val := reflect.ValueOf(form)
+
+ for typ.Kind() == reflect.Ptr {
+ typ = typ.Elem()
+ val = val.Elem()
+ }
+
+ for i := 0; i < typ.NumField(); i++ {
+ field := typ.Field(i)
+
+ fieldName := field.Tag.Get("form")
+ // Allow ignored fields in the struct
+ if fieldName == "-" {
+ continue
+ } else if len(fieldName) == 0 {
+ fieldName = util.ToSnakeCase(field.Name)
+ }
+
+ data[fieldName] = val.Field(i).Interface()
+ }
+}
+
+func getRuleBody(field reflect.StructField, prefix string) string {
+ for _, rule := range strings.Split(field.Tag.Get("binding"), ";") {
+ if strings.HasPrefix(rule, prefix) {
+ return rule[len(prefix) : len(rule)-1]
+ }
+ }
+ return ""
+}
+
+// GetSize get size int form tag
+func GetSize(field reflect.StructField) string {
+ return getRuleBody(field, "Size(")
+}
+
+// GetMinSize get minimal size in form tag
+func GetMinSize(field reflect.StructField) string {
+ return getRuleBody(field, "MinSize(")
+}
+
+// GetMaxSize get max size in form tag
+func GetMaxSize(field reflect.StructField) string {
+ return getRuleBody(field, "MaxSize(")
+}
+
+// GetInclude get include in form tag
+func GetInclude(field reflect.StructField) string {
+ return getRuleBody(field, "Include(")
+}
+
+// Validate populates the data with validation error (if any).
+func Validate(errs binding.Errors, data map[string]any, f any, l translation.Locale) binding.Errors {
+ if errs.Len() == 0 {
+ return errs
+ }
+
+ data["HasError"] = true
+ // If the field with name errs[0].FieldNames[0] is not found in form
+ // somehow, some code later on will panic on Data["ErrorMsg"].(string).
+ // So initialize it to some default.
+ data["ErrorMsg"] = l.Tr("form.unknown_error")
+ AssignForm(f, data)
+
+ typ := reflect.TypeOf(f)
+
+ if typ.Kind() == reflect.Ptr {
+ typ = typ.Elem()
+ }
+
+ if field, ok := typ.FieldByName(errs[0].FieldNames[0]); ok {
+ fieldName := field.Tag.Get("form")
+ if fieldName != "-" {
+ data["Err_"+field.Name] = true
+
+ trName := field.Tag.Get("locale")
+ if len(trName) == 0 {
+ trName = l.TrString("form." + field.Name)
+ } else {
+ trName = l.TrString(trName)
+ }
+
+ switch errs[0].Classification {
+ case binding.ERR_REQUIRED:
+ data["ErrorMsg"] = trName + l.TrString("form.require_error")
+ case binding.ERR_ALPHA_DASH:
+ data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_error")
+ case binding.ERR_ALPHA_DASH_DOT:
+ data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_dot_error")
+ case validation.ErrGitRefName:
+ data["ErrorMsg"] = trName + l.TrString("form.git_ref_name_error")
+ case binding.ERR_SIZE:
+ data["ErrorMsg"] = trName + l.TrString("form.size_error", GetSize(field))
+ case binding.ERR_MIN_SIZE:
+ data["ErrorMsg"] = trName + l.TrString("form.min_size_error", GetMinSize(field))
+ case binding.ERR_MAX_SIZE:
+ data["ErrorMsg"] = trName + l.TrString("form.max_size_error", GetMaxSize(field))
+ case binding.ERR_EMAIL:
+ data["ErrorMsg"] = trName + l.TrString("form.email_error")
+ case binding.ERR_URL:
+ data["ErrorMsg"] = trName + l.TrString("form.url_error", errs[0].Message)
+ case binding.ERR_INCLUDE:
+ data["ErrorMsg"] = trName + l.TrString("form.include_error", GetInclude(field))
+ case validation.ErrGlobPattern:
+ data["ErrorMsg"] = trName + l.TrString("form.glob_pattern_error", errs[0].Message)
+ case validation.ErrRegexPattern:
+ data["ErrorMsg"] = trName + l.TrString("form.regex_pattern_error", errs[0].Message)
+ case validation.ErrUsername:
+ if setting.Service.AllowDotsInUsernames {
+ data["ErrorMsg"] = trName + l.TrString("form.username_error")
+ } else {
+ data["ErrorMsg"] = trName + l.TrString("form.username_error_no_dots")
+ }
+ case validation.ErrInvalidGroupTeamMap:
+ data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message)
+ default:
+ msg := errs[0].Classification
+ if msg != "" && errs[0].Message != "" {
+ msg += ": "
+ }
+
+ msg += errs[0].Message
+ if msg == "" {
+ msg = l.TrString("form.unknown_error")
+ }
+ data["ErrorMsg"] = trName + ": " + msg
+ }
+ return errs
+ }
+ }
+ return errs
+}
diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go
new file mode 100644
index 0000000..f2d25f5
--- /dev/null
+++ b/modules/web/middleware/cookie.go
@@ -0,0 +1,85 @@
+// Copyright 2020 The Macaron Authors
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/session"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// SetRedirectToCookie convenience function to set the RedirectTo cookie consistently
+func SetRedirectToCookie(resp http.ResponseWriter, value string) {
+ SetSiteCookie(resp, "redirect_to", value, 0)
+}
+
+// DeleteRedirectToCookie convenience function to delete most cookies consistently
+func DeleteRedirectToCookie(resp http.ResponseWriter) {
+ SetSiteCookie(resp, "redirect_to", "", -1)
+}
+
+// GetSiteCookie returns given cookie value from request header.
+func GetSiteCookie(req *http.Request, name string) string {
+ cookie, err := req.Cookie(name)
+ if err != nil {
+ return ""
+ }
+ val, _ := url.QueryUnescape(cookie.Value)
+ return val
+}
+
+// SetSiteCookie returns given cookie value from request header.
+func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) {
+ // Previous versions would use a cookie path with a trailing /.
+ // These are more specific than cookies without a trailing /, so
+ // we need to delete these if they exist.
+ deleteLegacySiteCookie(resp, name)
+ cookie := &http.Cookie{
+ Name: name,
+ Value: url.QueryEscape(value),
+ MaxAge: maxAge,
+ Path: setting.SessionConfig.CookiePath,
+ Domain: setting.SessionConfig.Domain,
+ Secure: setting.SessionConfig.Secure,
+ HttpOnly: true,
+ SameSite: setting.SessionConfig.SameSite,
+ }
+ resp.Header().Add("Set-Cookie", cookie.String())
+}
+
+// deleteLegacySiteCookie deletes the cookie with the given name at the cookie
+// path with a trailing /, which would unintentionally override the cookie.
+func deleteLegacySiteCookie(resp http.ResponseWriter, name string) {
+ if setting.SessionConfig.CookiePath == "" || strings.HasSuffix(setting.SessionConfig.CookiePath, "/") {
+ // If the cookie path ends with /, no legacy cookies will take
+ // precedence, so do nothing. The exception is that cookies with no
+ // path could override other cookies, but it's complicated and we don't
+ // currently handle that.
+ return
+ }
+
+ cookie := &http.Cookie{
+ Name: name,
+ Value: "",
+ MaxAge: -1,
+ Path: setting.SessionConfig.CookiePath + "/",
+ Domain: setting.SessionConfig.Domain,
+ Secure: setting.SessionConfig.Secure,
+ HttpOnly: true,
+ SameSite: setting.SessionConfig.SameSite,
+ }
+ resp.Header().Add("Set-Cookie", cookie.String())
+}
+
+func init() {
+ session.BeforeRegenerateSession = append(session.BeforeRegenerateSession, func(resp http.ResponseWriter, _ *http.Request) {
+ // Ensure that a cookie with a trailing slash does not take precedence over
+ // the cookie written by the middleware.
+ deleteLegacySiteCookie(resp, setting.SessionConfig.CookieName)
+ })
+}
diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go
new file mode 100644
index 0000000..08d83f9
--- /dev/null
+++ b/modules/web/middleware/data.go
@@ -0,0 +1,63 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "context"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// ContextDataStore represents a data store
+type ContextDataStore interface {
+ GetData() ContextData
+}
+
+type ContextData map[string]any
+
+func (ds ContextData) GetData() ContextData {
+ return ds
+}
+
+func (ds ContextData) MergeFrom(other ContextData) ContextData {
+ for k, v := range other {
+ ds[k] = v
+ }
+ return ds
+}
+
+const ContextDataKeySignedUser = "SignedUser"
+
+type contextDataKeyType struct{}
+
+var contextDataKey contextDataKeyType
+
+func WithContextData(c context.Context) context.Context {
+ return context.WithValue(c, contextDataKey, make(ContextData, 10))
+}
+
+func GetContextData(c context.Context) ContextData {
+ if ds, ok := c.Value(contextDataKey).(ContextData); ok {
+ return ds
+ }
+ return nil
+}
+
+func CommonTemplateContextData() ContextData {
+ return ContextData{
+ "IsLandingPageOrganizations": setting.LandingPageURL == setting.LandingPageOrganizations,
+
+ "ShowRegistrationButton": setting.Service.ShowRegistrationButton,
+ "ShowMilestonesDashboardPage": setting.Service.ShowMilestonesDashboardPage,
+ "ShowFooterVersion": setting.Other.ShowFooterVersion,
+ "DisableDownloadSourceArchives": setting.Repository.DisableDownloadSourceArchives,
+
+ "EnableSwagger": setting.API.EnableSwagger,
+ "EnableOpenIDSignIn": setting.Service.EnableOpenIDSignIn,
+ "PageStartTime": time.Now(),
+
+ "RunModeIsProd": setting.IsProd,
+ }
+}
diff --git a/modules/web/middleware/flash.go b/modules/web/middleware/flash.go
new file mode 100644
index 0000000..88da204
--- /dev/null
+++ b/modules/web/middleware/flash.go
@@ -0,0 +1,65 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "fmt"
+ "html/template"
+ "net/url"
+)
+
+// Flash represents a one time data transfer between two requests.
+type Flash struct {
+ DataStore ContextDataStore
+ url.Values
+ ErrorMsg, WarningMsg, InfoMsg, SuccessMsg string
+}
+
+func (f *Flash) set(name, msg string, current ...bool) {
+ if f.Values == nil {
+ f.Values = make(map[string][]string)
+ }
+ showInCurrentPage := len(current) > 0 && current[0]
+ if showInCurrentPage {
+ // assign it to the context data, then the template can use ".Flash.XxxMsg" to render the message
+ f.DataStore.GetData()["Flash"] = f
+ } else {
+ // the message map will be saved into the cookie and be shown in next response (a new page response which decodes the cookie)
+ f.Set(name, msg)
+ }
+}
+
+func flashMsgStringOrHTML(msg any) string {
+ switch v := msg.(type) {
+ case string:
+ return v
+ case template.HTML:
+ return string(v)
+ }
+ panic(fmt.Sprintf("unknown type: %T", msg))
+}
+
+// Error sets error message
+func (f *Flash) Error(msg any, current ...bool) {
+ f.ErrorMsg = flashMsgStringOrHTML(msg)
+ f.set("error", f.ErrorMsg, current...)
+}
+
+// Warning sets warning message
+func (f *Flash) Warning(msg any, current ...bool) {
+ f.WarningMsg = flashMsgStringOrHTML(msg)
+ f.set("warning", f.WarningMsg, current...)
+}
+
+// Info sets info message
+func (f *Flash) Info(msg any, current ...bool) {
+ f.InfoMsg = flashMsgStringOrHTML(msg)
+ f.set("info", f.InfoMsg, current...)
+}
+
+// Success sets success message
+func (f *Flash) Success(msg any, current ...bool) {
+ f.SuccessMsg = flashMsgStringOrHTML(msg)
+ f.set("success", f.SuccessMsg, current...)
+}
diff --git a/modules/web/middleware/locale.go b/modules/web/middleware/locale.go
new file mode 100644
index 0000000..34a16f0
--- /dev/null
+++ b/modules/web/middleware/locale.go
@@ -0,0 +1,59 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/translation/i18n"
+
+ "golang.org/x/text/language"
+)
+
+// Locale handle locale
+func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
+ // 1. Check URL arguments.
+ lang := req.URL.Query().Get("lang")
+ changeLang := lang != ""
+
+ // 2. Get language information from cookies.
+ if len(lang) == 0 {
+ ck, _ := req.Cookie("lang")
+ if ck != nil {
+ lang = ck.Value
+ }
+ }
+
+ // Check again in case someone changes the supported language list.
+ if lang != "" && !i18n.DefaultLocales.HasLang(lang) {
+ lang = ""
+ changeLang = false
+ }
+
+ // 3. Get language information from 'Accept-Language'.
+ // The first element in the list is chosen to be the default language automatically.
+ if len(lang) == 0 {
+ tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
+ tag := translation.Match(tags...)
+ lang = tag.String()
+ }
+
+ if changeLang {
+ SetLocaleCookie(resp, lang, 1<<31-1)
+ }
+
+ return translation.NewLocale(lang)
+}
+
+// SetLocaleCookie convenience function to set the locale cookie consistently
+func SetLocaleCookie(resp http.ResponseWriter, lang string, maxAge int) {
+ SetSiteCookie(resp, "lang", lang, maxAge)
+}
+
+// DeleteLocaleCookie convenience function to delete the locale cookie consistently
+// Setting the lang cookie will trigger the middleware to reset the language to previous state.
+func DeleteLocaleCookie(resp http.ResponseWriter) {
+ SetSiteCookie(resp, "lang", "", -1)
+}
diff --git a/modules/web/middleware/request.go b/modules/web/middleware/request.go
new file mode 100644
index 0000000..0bb155d
--- /dev/null
+++ b/modules/web/middleware/request.go
@@ -0,0 +1,14 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package middleware
+
+import (
+ "net/http"
+ "strings"
+)
+
+// IsAPIPath returns true if the specified URL is an API path
+func IsAPIPath(req *http.Request) bool {
+ return strings.HasPrefix(req.URL.Path, "/api/")
+}
diff --git a/modules/web/route.go b/modules/web/route.go
new file mode 100644
index 0000000..805fcb4
--- /dev/null
+++ b/modules/web/route.go
@@ -0,0 +1,211 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/modules/web/middleware"
+
+ "gitea.com/go-chi/binding"
+ "github.com/go-chi/chi/v5"
+)
+
+// Bind binding an obj to a handler's context data
+func Bind[T any](_ T) http.HandlerFunc {
+ return func(resp http.ResponseWriter, req *http.Request) {
+ theObj := new(T) // create a new form obj for every request but not use obj directly
+ data := middleware.GetContextData(req.Context())
+ binding.Bind(req, theObj)
+ SetForm(data, theObj)
+ middleware.AssignForm(theObj, data)
+ }
+}
+
+// SetForm set the form object
+func SetForm(dataStore middleware.ContextDataStore, obj any) {
+ dataStore.GetData()["__form"] = obj
+}
+
+// GetForm returns the validate form information
+func GetForm(dataStore middleware.ContextDataStore) any {
+ return dataStore.GetData()["__form"]
+}
+
+// Route defines a route based on chi's router
+type Route struct {
+ R chi.Router
+ curGroupPrefix string
+ curMiddlewares []any
+}
+
+// NewRoute creates a new route
+func NewRoute() *Route {
+ r := chi.NewRouter()
+ return &Route{R: r}
+}
+
+// Use supports two middlewares
+func (r *Route) Use(middlewares ...any) {
+ for _, m := range middlewares {
+ if m != nil {
+ r.R.Use(toHandlerProvider(m))
+ }
+ }
+}
+
+// Group mounts a sub-Router along a `pattern` string.
+func (r *Route) Group(pattern string, fn func(), middlewares ...any) {
+ previousGroupPrefix := r.curGroupPrefix
+ previousMiddlewares := r.curMiddlewares
+ r.curGroupPrefix += pattern
+ r.curMiddlewares = append(r.curMiddlewares, middlewares...)
+
+ fn()
+
+ r.curGroupPrefix = previousGroupPrefix
+ r.curMiddlewares = previousMiddlewares
+}
+
+func (r *Route) getPattern(pattern string) string {
+ newPattern := r.curGroupPrefix + pattern
+ if !strings.HasPrefix(newPattern, "/") {
+ newPattern = "/" + newPattern
+ }
+ if newPattern == "/" {
+ return newPattern
+ }
+ return strings.TrimSuffix(newPattern, "/")
+}
+
+func (r *Route) wrapMiddlewareAndHandler(h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) {
+ handlerProviders := make([]func(http.Handler) http.Handler, 0, len(r.curMiddlewares)+len(h)+1)
+ for _, m := range r.curMiddlewares {
+ if m != nil {
+ handlerProviders = append(handlerProviders, toHandlerProvider(m))
+ }
+ }
+ for _, m := range h {
+ if h != nil {
+ handlerProviders = append(handlerProviders, toHandlerProvider(m))
+ }
+ }
+ middlewares := handlerProviders[:len(handlerProviders)-1]
+ handlerFunc := handlerProviders[len(handlerProviders)-1](nil).ServeHTTP
+ mockPoint := RouteMockPoint(MockAfterMiddlewares)
+ if mockPoint != nil {
+ middlewares = append(middlewares, mockPoint)
+ }
+ return middlewares, handlerFunc
+}
+
+// Methods adds the same handlers for multiple http "methods" (separated by ",").
+// If any method is invalid, the lower level router will panic.
+func (r *Route) Methods(methods, pattern string, h ...any) {
+ middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h)
+ fullPattern := r.getPattern(pattern)
+ if strings.Contains(methods, ",") {
+ methods := strings.Split(methods, ",")
+ for _, method := range methods {
+ r.R.With(middlewares...).Method(strings.TrimSpace(method), fullPattern, handlerFunc)
+ }
+ } else {
+ r.R.With(middlewares...).Method(methods, fullPattern, handlerFunc)
+ }
+}
+
+// Mount attaches another Route along ./pattern/*
+func (r *Route) Mount(pattern string, subR *Route) {
+ subR.Use(r.curMiddlewares...)
+ r.R.Mount(r.getPattern(pattern), subR.R)
+}
+
+// Any delegate requests for all methods
+func (r *Route) Any(pattern string, h ...any) {
+ middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h)
+ r.R.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc)
+}
+
+// Delete delegate delete method
+func (r *Route) Delete(pattern string, h ...any) {
+ r.Methods("DELETE", pattern, h...)
+}
+
+// Get delegate get method
+func (r *Route) Get(pattern string, h ...any) {
+ r.Methods("GET", pattern, h...)
+}
+
+// Head delegate head method
+func (r *Route) Head(pattern string, h ...any) {
+ r.Methods("HEAD", pattern, h...)
+}
+
+// Post delegate post method
+func (r *Route) Post(pattern string, h ...any) {
+ r.Methods("POST", pattern, h...)
+}
+
+// Put delegate put method
+func (r *Route) Put(pattern string, h ...any) {
+ r.Methods("PUT", pattern, h...)
+}
+
+// Patch delegate patch method
+func (r *Route) Patch(pattern string, h ...any) {
+ r.Methods("PATCH", pattern, h...)
+}
+
+// ServeHTTP implements http.Handler
+func (r *Route) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ r.R.ServeHTTP(w, req)
+}
+
+// NotFound defines a handler to respond whenever a route could not be found.
+func (r *Route) NotFound(h http.HandlerFunc) {
+ r.R.NotFound(h)
+}
+
+// Combo delegates requests to Combo
+func (r *Route) Combo(pattern string, h ...any) *Combo {
+ return &Combo{r, pattern, h}
+}
+
+// Combo represents a tiny group routes with same pattern
+type Combo struct {
+ r *Route
+ pattern string
+ h []any
+}
+
+// Get delegates Get method
+func (c *Combo) Get(h ...any) *Combo {
+ c.r.Get(c.pattern, append(c.h, h...)...)
+ return c
+}
+
+// Post delegates Post method
+func (c *Combo) Post(h ...any) *Combo {
+ c.r.Post(c.pattern, append(c.h, h...)...)
+ return c
+}
+
+// Delete delegates Delete method
+func (c *Combo) Delete(h ...any) *Combo {
+ c.r.Delete(c.pattern, append(c.h, h...)...)
+ return c
+}
+
+// Put delegates Put method
+func (c *Combo) Put(h ...any) *Combo {
+ c.r.Put(c.pattern, append(c.h, h...)...)
+ return c
+}
+
+// Patch delegates Patch method
+func (c *Combo) Patch(h ...any) *Combo {
+ c.r.Patch(c.pattern, append(c.h, h...)...)
+ return c
+}
diff --git a/modules/web/route_test.go b/modules/web/route_test.go
new file mode 100644
index 0000000..d8015d6
--- /dev/null
+++ b/modules/web/route_test.go
@@ -0,0 +1,179 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ chi "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRoute1(t *testing.T) {
+ buff := bytes.NewBufferString("")
+ recorder := httptest.NewRecorder()
+ recorder.Body = buff
+
+ r := NewRoute()
+ r.Get("/{username}/{reponame}/{type:issues|pulls}", func(resp http.ResponseWriter, req *http.Request) {
+ username := chi.URLParam(req, "username")
+ assert.EqualValues(t, "gitea", username)
+ reponame := chi.URLParam(req, "reponame")
+ assert.EqualValues(t, "gitea", reponame)
+ tp := chi.URLParam(req, "type")
+ assert.EqualValues(t, "issues", tp)
+ })
+
+ req, err := http.NewRequest("GET", "http://localhost:8000/gitea/gitea/issues", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+}
+
+func TestRoute2(t *testing.T) {
+ buff := bytes.NewBufferString("")
+ recorder := httptest.NewRecorder()
+ recorder.Body = buff
+
+ hit := -1
+
+ r := NewRoute()
+ r.Group("/{username}/{reponame}", func() {
+ r.Group("", func() {
+ r.Get("/{type:issues|pulls}", func(resp http.ResponseWriter, req *http.Request) {
+ username := chi.URLParam(req, "username")
+ assert.EqualValues(t, "gitea", username)
+ reponame := chi.URLParam(req, "reponame")
+ assert.EqualValues(t, "gitea", reponame)
+ tp := chi.URLParam(req, "type")
+ assert.EqualValues(t, "issues", tp)
+ hit = 0
+ })
+
+ r.Get("/{type:issues|pulls}/{index}", func(resp http.ResponseWriter, req *http.Request) {
+ username := chi.URLParam(req, "username")
+ assert.EqualValues(t, "gitea", username)
+ reponame := chi.URLParam(req, "reponame")
+ assert.EqualValues(t, "gitea", reponame)
+ tp := chi.URLParam(req, "type")
+ assert.EqualValues(t, "issues", tp)
+ index := chi.URLParam(req, "index")
+ assert.EqualValues(t, "1", index)
+ hit = 1
+ })
+ }, func(resp http.ResponseWriter, req *http.Request) {
+ if stop, err := strconv.Atoi(req.FormValue("stop")); err == nil {
+ hit = stop
+ resp.WriteHeader(http.StatusOK)
+ }
+ })
+
+ r.Group("/issues/{index}", func() {
+ r.Get("/view", func(resp http.ResponseWriter, req *http.Request) {
+ username := chi.URLParam(req, "username")
+ assert.EqualValues(t, "gitea", username)
+ reponame := chi.URLParam(req, "reponame")
+ assert.EqualValues(t, "gitea", reponame)
+ index := chi.URLParam(req, "index")
+ assert.EqualValues(t, "1", index)
+ hit = 2
+ })
+ })
+ })
+
+ req, err := http.NewRequest("GET", "http://localhost:8000/gitea/gitea/issues", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 0, hit)
+
+ req, err = http.NewRequest("GET", "http://localhost:8000/gitea/gitea/issues/1", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 1, hit)
+
+ req, err = http.NewRequest("GET", "http://localhost:8000/gitea/gitea/issues/1?stop=100", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 100, hit)
+
+ req, err = http.NewRequest("GET", "http://localhost:8000/gitea/gitea/issues/1/view", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 2, hit)
+}
+
+func TestRoute3(t *testing.T) {
+ buff := bytes.NewBufferString("")
+ recorder := httptest.NewRecorder()
+ recorder.Body = buff
+
+ hit := -1
+
+ m := NewRoute()
+ r := NewRoute()
+ r.Mount("/api/v1", m)
+
+ m.Group("/repos", func() {
+ m.Group("/{username}/{reponame}", func() {
+ m.Group("/branch_protections", func() {
+ m.Get("", func(resp http.ResponseWriter, req *http.Request) {
+ hit = 0
+ })
+ m.Post("", func(resp http.ResponseWriter, req *http.Request) {
+ hit = 1
+ })
+ m.Group("/{name}", func() {
+ m.Get("", func(resp http.ResponseWriter, req *http.Request) {
+ hit = 2
+ })
+ m.Patch("", func(resp http.ResponseWriter, req *http.Request) {
+ hit = 3
+ })
+ m.Delete("", func(resp http.ResponseWriter, req *http.Request) {
+ hit = 4
+ })
+ })
+ })
+ })
+ })
+
+ req, err := http.NewRequest("GET", "http://localhost:8000/api/v1/repos/gitea/gitea/branch_protections", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 0, hit)
+
+ req, err = http.NewRequest("POST", "http://localhost:8000/api/v1/repos/gitea/gitea/branch_protections", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code, http.StatusOK)
+ assert.EqualValues(t, 1, hit)
+
+ req, err = http.NewRequest("GET", "http://localhost:8000/api/v1/repos/gitea/gitea/branch_protections/master", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 2, hit)
+
+ req, err = http.NewRequest("PATCH", "http://localhost:8000/api/v1/repos/gitea/gitea/branch_protections/master", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 3, hit)
+
+ req, err = http.NewRequest("DELETE", "http://localhost:8000/api/v1/repos/gitea/gitea/branch_protections/master", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.EqualValues(t, http.StatusOK, recorder.Code)
+ assert.EqualValues(t, 4, hit)
+}
diff --git a/modules/web/routemock.go b/modules/web/routemock.go
new file mode 100644
index 0000000..cb41f63
--- /dev/null
+++ b/modules/web/routemock.go
@@ -0,0 +1,61 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// MockAfterMiddlewares is a general mock point, it's between middlewares and the handler
+const MockAfterMiddlewares = "MockAfterMiddlewares"
+
+var routeMockPoints = map[string]func(next http.Handler) http.Handler{}
+
+// RouteMockPoint registers a mock point as a middleware for testing, example:
+//
+// r.Use(web.RouteMockPoint("my-mock-point-1"))
+// r.Get("/foo", middleware2, web.RouteMockPoint("my-mock-point-2"), middleware2, handler)
+//
+// Then use web.RouteMock to mock the route execution.
+// It only takes effect in testing mode (setting.IsInTesting == true).
+func RouteMockPoint(pointName string) func(next http.Handler) http.Handler {
+ if !setting.IsInTesting {
+ return nil
+ }
+ routeMockPoints[pointName] = nil
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h := routeMockPoints[pointName]; h != nil {
+ h(next).ServeHTTP(w, r)
+ } else {
+ next.ServeHTTP(w, r)
+ }
+ })
+ }
+}
+
+// RouteMock uses the registered mock point to mock the route execution, example:
+//
+// defer web.RouteMockReset()
+// web.RouteMock(web.MockAfterMiddlewares, func(ctx *context.Context) {
+// ctx.WriteResponse(...)
+// }
+//
+// Then the mock function will be executed as a middleware at the mock point.
+// It only takes effect in testing mode (setting.IsInTesting == true).
+func RouteMock(pointName string, h any) {
+ if _, ok := routeMockPoints[pointName]; !ok {
+ panic("route mock point not found: " + pointName)
+ }
+ routeMockPoints[pointName] = toHandlerProvider(h)
+}
+
+// RouteMockReset resets all mock points (no mock anymore)
+func RouteMockReset() {
+ for k := range routeMockPoints {
+ routeMockPoints[k] = nil // keep the keys because RouteMock will check the keys to make sure no misspelling
+ }
+}
diff --git a/modules/web/routemock_test.go b/modules/web/routemock_test.go
new file mode 100644
index 0000000..cd99b99
--- /dev/null
+++ b/modules/web/routemock_test.go
@@ -0,0 +1,71 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRouteMock(t *testing.T) {
+ setting.IsInTesting = true
+
+ r := NewRoute()
+ middleware1 := func(resp http.ResponseWriter, req *http.Request) {
+ resp.Header().Set("X-Test-Middleware1", "m1")
+ }
+ middleware2 := func(resp http.ResponseWriter, req *http.Request) {
+ resp.Header().Set("X-Test-Middleware2", "m2")
+ }
+ handler := func(resp http.ResponseWriter, req *http.Request) {
+ resp.Header().Set("X-Test-Handler", "h")
+ }
+ r.Get("/foo", middleware1, RouteMockPoint("mock-point"), middleware2, handler)
+
+ // normal request
+ recorder := httptest.NewRecorder()
+ req, err := http.NewRequest("GET", "http://localhost:8000/foo", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.Len(t, recorder.Header(), 3)
+ assert.EqualValues(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
+ assert.EqualValues(t, "m2", recorder.Header().Get("X-Test-Middleware2"))
+ assert.EqualValues(t, "h", recorder.Header().Get("X-Test-Handler"))
+ RouteMockReset()
+
+ // mock at "mock-point"
+ RouteMock("mock-point", func(resp http.ResponseWriter, req *http.Request) {
+ resp.Header().Set("X-Test-MockPoint", "a")
+ resp.WriteHeader(http.StatusOK)
+ })
+ recorder = httptest.NewRecorder()
+ req, err = http.NewRequest("GET", "http://localhost:8000/foo", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.Len(t, recorder.Header(), 2)
+ assert.EqualValues(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
+ assert.EqualValues(t, "a", recorder.Header().Get("X-Test-MockPoint"))
+ RouteMockReset()
+
+ // mock at MockAfterMiddlewares
+ RouteMock(MockAfterMiddlewares, func(resp http.ResponseWriter, req *http.Request) {
+ resp.Header().Set("X-Test-MockPoint", "b")
+ resp.WriteHeader(http.StatusOK)
+ })
+ recorder = httptest.NewRecorder()
+ req, err = http.NewRequest("GET", "http://localhost:8000/foo", nil)
+ require.NoError(t, err)
+ r.ServeHTTP(recorder, req)
+ assert.Len(t, recorder.Header(), 3)
+ assert.EqualValues(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
+ assert.EqualValues(t, "m2", recorder.Header().Get("X-Test-Middleware2"))
+ assert.EqualValues(t, "b", recorder.Header().Get("X-Test-MockPoint"))
+ RouteMockReset()
+}
diff --git a/modules/web/routing/context.go b/modules/web/routing/context.go
new file mode 100644
index 0000000..c5e85a4
--- /dev/null
+++ b/modules/web/routing/context.go
@@ -0,0 +1,49 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "context"
+ "net/http"
+)
+
+type contextKeyType struct{}
+
+var contextKey contextKeyType
+
+// UpdateFuncInfo updates a context's func info
+func UpdateFuncInfo(ctx context.Context, funcInfo *FuncInfo) {
+ record, ok := ctx.Value(contextKey).(*requestRecord)
+ if !ok {
+ return
+ }
+
+ record.lock.Lock()
+ record.funcInfo = funcInfo
+ record.lock.Unlock()
+}
+
+// MarkLongPolling marks the request is a long-polling request, and the logger may output different message for it
+func MarkLongPolling(resp http.ResponseWriter, req *http.Request) {
+ record, ok := req.Context().Value(contextKey).(*requestRecord)
+ if !ok {
+ return
+ }
+
+ record.lock.Lock()
+ record.isLongPolling = true
+ record.lock.Unlock()
+}
+
+// UpdatePanicError updates a context's error info, a panic may be recovered by other middlewares, but we still need to know that.
+func UpdatePanicError(ctx context.Context, err any) {
+ record, ok := ctx.Value(contextKey).(*requestRecord)
+ if !ok {
+ return
+ }
+
+ record.lock.Lock()
+ record.panicError = err
+ record.lock.Unlock()
+}
diff --git a/modules/web/routing/funcinfo.go b/modules/web/routing/funcinfo.go
new file mode 100644
index 0000000..f4e9731
--- /dev/null
+++ b/modules/web/routing/funcinfo.go
@@ -0,0 +1,172 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "fmt"
+ "reflect"
+ "runtime"
+ "strings"
+ "sync"
+)
+
+var (
+ funcInfoMap = map[uintptr]*FuncInfo{}
+ funcInfoNameMap = map[string]*FuncInfo{}
+ funcInfoMapMu sync.RWMutex
+)
+
+// FuncInfo contains information about the function to be logged by the router log
+type FuncInfo struct {
+ file string
+ shortFile string
+ line int
+ name string
+ shortName string
+}
+
+// String returns a string form of the FuncInfo for logging
+func (info *FuncInfo) String() string {
+ if info == nil {
+ return "unknown-handler"
+ }
+ return fmt.Sprintf("%s:%d(%s)", info.shortFile, info.line, info.shortName)
+}
+
+// GetFuncInfo returns the FuncInfo for a provided function and friendlyname
+func GetFuncInfo(fn any, friendlyName ...string) *FuncInfo {
+ // ptr represents the memory position of the function passed in as v.
+ // This will be used as program counter in FuncForPC below
+ ptr := reflect.ValueOf(fn).Pointer()
+
+ // if we have been provided with a friendlyName look for the named funcs
+ if len(friendlyName) == 1 {
+ name := friendlyName[0]
+ funcInfoMapMu.RLock()
+ info, ok := funcInfoNameMap[name]
+ funcInfoMapMu.RUnlock()
+ if ok {
+ return info
+ }
+ }
+
+ // Otherwise attempt to get pre-cached information for this function pointer
+ funcInfoMapMu.RLock()
+ info, ok := funcInfoMap[ptr]
+ funcInfoMapMu.RUnlock()
+
+ if ok {
+ if len(friendlyName) == 1 {
+ name := friendlyName[0]
+ info = copyFuncInfo(info)
+ info.shortName = name
+
+ funcInfoNameMap[name] = info
+ funcInfoMapMu.Lock()
+ funcInfoNameMap[name] = info
+ funcInfoMapMu.Unlock()
+ }
+ return info
+ }
+
+ // This is likely the first time we have seen this function
+ //
+ // Get the runtime.func for this function (if we can)
+ f := runtime.FuncForPC(ptr)
+ if f != nil {
+ info = convertToFuncInfo(f)
+
+ // cache this info globally
+ funcInfoMapMu.Lock()
+ funcInfoMap[ptr] = info
+
+ // if we have been provided with a friendlyName override the short name we've generated
+ if len(friendlyName) == 1 {
+ name := friendlyName[0]
+ info = copyFuncInfo(info)
+ info.shortName = name
+ funcInfoNameMap[name] = info
+ }
+ funcInfoMapMu.Unlock()
+ }
+ return info
+}
+
+// convertToFuncInfo take a runtime.Func and convert it to a logFuncInfo, fill in shorten filename, etc
+func convertToFuncInfo(f *runtime.Func) *FuncInfo {
+ file, line := f.FileLine(f.Entry())
+
+ info := &FuncInfo{
+ file: strings.ReplaceAll(file, "\\", "/"),
+ line: line,
+ name: f.Name(),
+ }
+
+ // only keep last 2 names in path, fall back to funcName if not
+ info.shortFile = shortenFilename(info.file, info.name)
+
+ // remove package prefix. eg: "xxx.com/pkg1/pkg2.foo" => "pkg2.foo"
+ pos := strings.LastIndexByte(info.name, '/')
+ if pos >= 0 {
+ info.shortName = info.name[pos+1:]
+ } else {
+ info.shortName = info.name
+ }
+
+ // remove ".func[0-9]*" suffix for anonymous func
+ info.shortName = trimAnonymousFunctionSuffix(info.shortName)
+
+ return info
+}
+
+func copyFuncInfo(l *FuncInfo) *FuncInfo {
+ return &FuncInfo{
+ file: l.file,
+ shortFile: l.shortFile,
+ line: l.line,
+ name: l.name,
+ shortName: l.shortName,
+ }
+}
+
+// shortenFilename generates a short source code filename from a full package path, eg: "code.gitea.io/routers/common/logger_context.go" => "common/logger_context.go"
+func shortenFilename(filename, fallback string) string {
+ if filename == "" {
+ return fallback
+ }
+ if lastIndex := strings.LastIndexByte(filename, '/'); lastIndex >= 0 {
+ if secondLastIndex := strings.LastIndexByte(filename[:lastIndex], '/'); secondLastIndex >= 0 {
+ return filename[secondLastIndex+1:]
+ }
+ }
+ return filename
+}
+
+// trimAnonymousFunctionSuffix trims ".func[0-9]*" from the end of anonymous function names, we only want to see the main function names in logs
+func trimAnonymousFunctionSuffix(name string) string {
+ // if the name is an anonymous name, it should be like "{main-function}.func1", so the length can not be less than 7
+ if len(name) < 7 {
+ return name
+ }
+
+ funcSuffixIndex := strings.LastIndex(name, ".func")
+ if funcSuffixIndex < 0 {
+ return name
+ }
+
+ hasFuncSuffix := true
+
+ // len(".func") = 5
+ for i := funcSuffixIndex + 5; i < len(name); i++ {
+ if name[i] < '0' || name[i] > '9' {
+ hasFuncSuffix = false
+ break
+ }
+ }
+
+ if hasFuncSuffix {
+ return name[:funcSuffixIndex]
+ }
+ return name
+}
diff --git a/modules/web/routing/funcinfo_test.go b/modules/web/routing/funcinfo_test.go
new file mode 100644
index 0000000..2ab5960
--- /dev/null
+++ b/modules/web/routing/funcinfo_test.go
@@ -0,0 +1,80 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "fmt"
+ "testing"
+)
+
+func Test_shortenFilename(t *testing.T) {
+ tests := []struct {
+ filename string
+ fallback string
+ expected string
+ }{
+ {
+ "code.gitea.io/routers/common/logger_context.go",
+ "NO_FALLBACK",
+ "common/logger_context.go",
+ },
+ {
+ "common/logger_context.go",
+ "NO_FALLBACK",
+ "common/logger_context.go",
+ },
+ {
+ "logger_context.go",
+ "NO_FALLBACK",
+ "logger_context.go",
+ },
+ {
+ "",
+ "USE_FALLBACK",
+ "USE_FALLBACK",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("shortenFilename('%s')", tt.filename), func(t *testing.T) {
+ if gotShort := shortenFilename(tt.filename, tt.fallback); gotShort != tt.expected {
+ t.Errorf("shortenFilename('%s'), expect '%s', but get '%s'", tt.filename, tt.expected, gotShort)
+ }
+ })
+ }
+}
+
+func Test_trimAnonymousFunctionSuffix(t *testing.T) {
+ tests := []struct {
+ name string
+ want string
+ }{
+ {
+ "notAnonymous",
+ "notAnonymous",
+ },
+ {
+ "anonymous.func1",
+ "anonymous",
+ },
+ {
+ "notAnonymous.funca",
+ "notAnonymous.funca",
+ },
+ {
+ "anonymous.func100",
+ "anonymous",
+ },
+ {
+ "anonymous.func100.func6",
+ "anonymous.func100",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := trimAnonymousFunctionSuffix(tt.name); got != tt.want {
+ t.Errorf("trimAnonymousFunctionSuffix() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/web/routing/logger.go b/modules/web/routing/logger.go
new file mode 100644
index 0000000..5f3a759
--- /dev/null
+++ b/modules/web/routing/logger.go
@@ -0,0 +1,109 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/web/types"
+)
+
+// NewLoggerHandler is a handler that will log routing to the router log taking account of
+// routing information
+func NewLoggerHandler() func(next http.Handler) http.Handler {
+ manager := requestRecordsManager{
+ requestRecords: map[uint64]*requestRecord{},
+ }
+ manager.startSlowQueryDetector(3 * time.Second)
+
+ logger := log.GetLogger("router")
+ manager.print = logPrinter(logger)
+ return manager.handler
+}
+
+var (
+ startMessage = log.NewColoredValue("started ", log.DEBUG.ColorAttributes()...)
+ slowMessage = log.NewColoredValue("slow ", log.WARN.ColorAttributes()...)
+ pollingMessage = log.NewColoredValue("polling ", log.INFO.ColorAttributes()...)
+ failedMessage = log.NewColoredValue("failed ", log.WARN.ColorAttributes()...)
+ completedMessage = log.NewColoredValue("completed", log.INFO.ColorAttributes()...)
+ unknownHandlerMessage = log.NewColoredValue("completed", log.ERROR.ColorAttributes()...)
+)
+
+func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
+ return func(trigger Event, record *requestRecord) {
+ if trigger == StartEvent {
+ if !logger.LevelEnabled(log.TRACE) {
+ // for performance, if the "started" message shouldn't be logged, we just return as early as possible
+ // developers can set the router log level to TRACE to get the "started" request messages.
+ return
+ }
+ // when a request starts, we have no information about the handler function information, we only have the request path
+ req := record.request
+ logger.Trace("router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr)
+ return
+ }
+
+ req := record.request
+
+ // Get data from the record
+ record.lock.Lock()
+ handlerFuncInfo := record.funcInfo.String()
+ isLongPolling := record.isLongPolling
+ isUnknownHandler := record.funcInfo == nil
+ panicErr := record.panicError
+ record.lock.Unlock()
+
+ if trigger == StillExecutingEvent {
+ message := slowMessage
+ logf := logger.Warn
+ if isLongPolling {
+ logf = logger.Info
+ message = pollingMessage
+ }
+ logf("router: %s %v %s for %s, elapsed %v @ %s",
+ message,
+ log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
+ log.ColoredTime(time.Since(record.startTime)),
+ handlerFuncInfo,
+ )
+ return
+ }
+
+ if panicErr != nil {
+ logger.Warn("router: %s %v %s for %s, panic in %v @ %s, err=%v",
+ failedMessage,
+ log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
+ log.ColoredTime(time.Since(record.startTime)),
+ handlerFuncInfo,
+ panicErr,
+ )
+ return
+ }
+
+ var status int
+ if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
+ status = v.WrittenStatus()
+ }
+ logf := logger.Info
+ if strings.HasPrefix(req.RequestURI, "/assets/") {
+ logf = logger.Trace
+ }
+ message := completedMessage
+ if isUnknownHandler {
+ logf = logger.Error
+ message = unknownHandlerMessage
+ }
+
+ logf("router: %s %v %s for %s, %v %v in %v @ %s",
+ message,
+ log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
+ log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)),
+ handlerFuncInfo,
+ )
+ }
+}
diff --git a/modules/web/routing/logger_manager.go b/modules/web/routing/logger_manager.go
new file mode 100644
index 0000000..aa25ec3
--- /dev/null
+++ b/modules/web/routing/logger_manager.go
@@ -0,0 +1,124 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "context"
+ "net/http"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/process"
+)
+
+// Event indicates when the printer is triggered
+type Event int
+
+const (
+ // StartEvent at the beginning of a request
+ StartEvent Event = iota
+
+ // StillExecutingEvent the request is still executing
+ StillExecutingEvent
+
+ // EndEvent the request has ended (either completed or failed)
+ EndEvent
+)
+
+// Printer is used to output the log for a request
+type Printer func(trigger Event, record *requestRecord)
+
+type requestRecordsManager struct {
+ print Printer
+
+ lock sync.Mutex
+
+ requestRecords map[uint64]*requestRecord
+ count uint64
+}
+
+func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Duration) {
+ go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: SlowQueryDetector", process.SystemProcessType, true)
+ defer finished()
+ // This go-routine checks all active requests every second.
+ // If a request has been running for a long time (eg: /user/events), we also print a log with "still-executing" message
+ // After the "still-executing" log is printed, the record will be removed from the map to prevent from duplicated logs in future
+
+ // We do not care about accurate duration here. It just does the check periodically, 0.5s or 1.5s are all OK.
+ t := time.NewTicker(time.Second)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ now := time.Now()
+
+ var slowRequests []*requestRecord
+
+ // find all slow requests with lock
+ manager.lock.Lock()
+ for index, record := range manager.requestRecords {
+ if now.Sub(record.startTime) < threshold {
+ continue
+ }
+
+ slowRequests = append(slowRequests, record)
+ delete(manager.requestRecords, index)
+ }
+ manager.lock.Unlock()
+
+ // print logs for slow requests
+ for _, record := range slowRequests {
+ manager.print(StillExecutingEvent, record)
+ }
+ }
+ }
+ })
+}
+
+func (manager *requestRecordsManager) handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ record := &requestRecord{
+ startTime: time.Now(),
+ request: req,
+ responseWriter: w,
+ }
+
+ // generate a record index an insert into the map
+ manager.lock.Lock()
+ record.index = manager.count
+ manager.count++
+ manager.requestRecords[record.index] = record
+ manager.lock.Unlock()
+
+ defer func() {
+ // just in case there is a panic. now the panics are all recovered in middleware.go
+ localPanicErr := recover()
+ if localPanicErr != nil {
+ record.lock.Lock()
+ record.panicError = localPanicErr
+ record.lock.Unlock()
+ }
+
+ // remove from the record map
+ manager.lock.Lock()
+ delete(manager.requestRecords, record.index)
+ manager.lock.Unlock()
+
+ // log the end of the request
+ manager.print(EndEvent, record)
+
+ if localPanicErr != nil {
+ // the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
+ panic(localPanicErr)
+ }
+ }()
+
+ req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
+ manager.print(StartEvent, record)
+ next.ServeHTTP(w, req)
+ })
+}
diff --git a/modules/web/routing/requestrecord.go b/modules/web/routing/requestrecord.go
new file mode 100644
index 0000000..cc61fc4
--- /dev/null
+++ b/modules/web/routing/requestrecord.go
@@ -0,0 +1,28 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package routing
+
+import (
+ "net/http"
+ "sync"
+ "time"
+)
+
+type requestRecord struct {
+ // index of the record in the records map
+ index uint64
+
+ // immutable fields
+ startTime time.Time
+ request *http.Request
+ responseWriter http.ResponseWriter
+
+ // mutex
+ lock sync.RWMutex
+
+ // mutable fields
+ isLongPolling bool
+ funcInfo *FuncInfo
+ panicError any
+}
diff --git a/modules/web/types/response.go b/modules/web/types/response.go
new file mode 100644
index 0000000..834f491
--- /dev/null
+++ b/modules/web/types/response.go
@@ -0,0 +1,10 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package types
+
+// ResponseStatusProvider is an interface to get the written status in the response
+// Many packages need this interface, so put it in the separate package to avoid import cycle
+type ResponseStatusProvider interface {
+ WrittenStatus() int
+}
diff --git a/modules/webhook/structs.go b/modules/webhook/structs.go
new file mode 100644
index 0000000..927a91a
--- /dev/null
+++ b/modules/webhook/structs.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+// HookEvents is a set of web hook events
+type HookEvents struct {
+ Create bool `json:"create"`
+ Delete bool `json:"delete"`
+ Fork bool `json:"fork"`
+ Issues bool `json:"issues"`
+ IssueAssign bool `json:"issue_assign"`
+ IssueLabel bool `json:"issue_label"`
+ IssueMilestone bool `json:"issue_milestone"`
+ IssueComment bool `json:"issue_comment"`
+ Push bool `json:"push"`
+ PullRequest bool `json:"pull_request"`
+ PullRequestAssign bool `json:"pull_request_assign"`
+ PullRequestLabel bool `json:"pull_request_label"`
+ PullRequestMilestone bool `json:"pull_request_milestone"`
+ PullRequestComment bool `json:"pull_request_comment"`
+ PullRequestReview bool `json:"pull_request_review"`
+ PullRequestSync bool `json:"pull_request_sync"`
+ PullRequestReviewRequest bool `json:"pull_request_review_request"`
+ Wiki bool `json:"wiki"`
+ Repository bool `json:"repository"`
+ Release bool `json:"release"`
+ Package bool `json:"package"`
+}
+
+// HookEvent represents events that will delivery hook.
+type HookEvent struct {
+ PushOnly bool `json:"push_only"`
+ SendEverything bool `json:"send_everything"`
+ ChooseEvents bool `json:"choose_events"`
+ BranchFilter string `json:"branch_filter"`
+
+ HookEvents `json:"events"`
+}
diff --git a/modules/webhook/type.go b/modules/webhook/type.go
new file mode 100644
index 0000000..244dc42
--- /dev/null
+++ b/modules/webhook/type.go
@@ -0,0 +1,100 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+// HookEventType is the type of a hook event
+type HookEventType string
+
+// Types of hook events
+const (
+ HookEventCreate HookEventType = "create"
+ HookEventDelete HookEventType = "delete"
+ HookEventFork HookEventType = "fork"
+ HookEventPush HookEventType = "push"
+ HookEventIssues HookEventType = "issues"
+ HookEventIssueAssign HookEventType = "issue_assign"
+ HookEventIssueLabel HookEventType = "issue_label"
+ HookEventIssueMilestone HookEventType = "issue_milestone"
+ HookEventIssueComment HookEventType = "issue_comment"
+ HookEventPullRequest HookEventType = "pull_request"
+ HookEventPullRequestAssign HookEventType = "pull_request_assign"
+ HookEventPullRequestLabel HookEventType = "pull_request_label"
+ HookEventPullRequestMilestone HookEventType = "pull_request_milestone"
+ HookEventPullRequestComment HookEventType = "pull_request_comment"
+ HookEventPullRequestReviewApproved HookEventType = "pull_request_review_approved"
+ HookEventPullRequestReviewRejected HookEventType = "pull_request_review_rejected"
+ HookEventPullRequestReviewComment HookEventType = "pull_request_review_comment"
+ HookEventPullRequestSync HookEventType = "pull_request_sync"
+ HookEventPullRequestReviewRequest HookEventType = "pull_request_review_request"
+ HookEventWiki HookEventType = "wiki"
+ HookEventRepository HookEventType = "repository"
+ HookEventRelease HookEventType = "release"
+ HookEventPackage HookEventType = "package"
+ HookEventSchedule HookEventType = "schedule"
+ HookEventWorkflowDispatch HookEventType = "workflow_dispatch"
+)
+
+// Event returns the HookEventType as an event string
+func (h HookEventType) Event() string {
+ switch h {
+ case HookEventCreate:
+ return "create"
+ case HookEventDelete:
+ return "delete"
+ case HookEventFork:
+ return "fork"
+ case HookEventPush:
+ return "push"
+ case HookEventIssues, HookEventIssueAssign, HookEventIssueLabel, HookEventIssueMilestone:
+ return "issues"
+ case HookEventPullRequest, HookEventPullRequestAssign, HookEventPullRequestLabel, HookEventPullRequestMilestone,
+ HookEventPullRequestSync, HookEventPullRequestReviewRequest:
+ return "pull_request"
+ case HookEventIssueComment, HookEventPullRequestComment:
+ return "issue_comment"
+ case HookEventPullRequestReviewApproved:
+ return "pull_request_approved"
+ case HookEventPullRequestReviewRejected:
+ return "pull_request_rejected"
+ case HookEventPullRequestReviewComment:
+ return "pull_request_comment"
+ case HookEventWiki:
+ return "wiki"
+ case HookEventRepository:
+ return "repository"
+ case HookEventRelease:
+ return "release"
+ }
+ return ""
+}
+
+// HookType is the type of a webhook
+type HookType = string
+
+// Types of webhooks
+const (
+ FORGEJO HookType = "forgejo"
+ GITEA HookType = "gitea"
+ GOGS HookType = "gogs"
+ SLACK HookType = "slack"
+ DISCORD HookType = "discord"
+ DINGTALK HookType = "dingtalk"
+ TELEGRAM HookType = "telegram"
+ MSTEAMS HookType = "msteams"
+ FEISHU HookType = "feishu"
+ MATRIX HookType = "matrix"
+ WECHATWORK HookType = "wechatwork"
+ PACKAGIST HookType = "packagist"
+ SOURCEHUT_BUILDS HookType = "sourcehut_builds" //nolint:revive
+)
+
+// HookStatus is the status of a web hook
+type HookStatus int
+
+// Possible statuses of a web hook
+const (
+ HookStatusNone HookStatus = iota
+ HookStatusSucceed
+ HookStatusFail
+)
diff --git a/modules/zstd/option.go b/modules/zstd/option.go
new file mode 100644
index 0000000..916a390
--- /dev/null
+++ b/modules/zstd/option.go
@@ -0,0 +1,46 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package zstd
+
+import "github.com/klauspost/compress/zstd"
+
+type WriterOption = zstd.EOption
+
+var (
+ WithEncoderCRC = zstd.WithEncoderCRC
+ WithEncoderConcurrency = zstd.WithEncoderConcurrency
+ WithWindowSize = zstd.WithWindowSize
+ WithEncoderPadding = zstd.WithEncoderPadding
+ WithEncoderLevel = zstd.WithEncoderLevel
+ WithZeroFrames = zstd.WithZeroFrames
+ WithAllLitEntropyCompression = zstd.WithAllLitEntropyCompression
+ WithNoEntropyCompression = zstd.WithNoEntropyCompression
+ WithSingleSegment = zstd.WithSingleSegment
+ WithLowerEncoderMem = zstd.WithLowerEncoderMem
+ WithEncoderDict = zstd.WithEncoderDict
+ WithEncoderDictRaw = zstd.WithEncoderDictRaw
+)
+
+type EncoderLevel = zstd.EncoderLevel
+
+const (
+ SpeedFastest EncoderLevel = zstd.SpeedFastest
+ SpeedDefault EncoderLevel = zstd.SpeedDefault
+ SpeedBetterCompression EncoderLevel = zstd.SpeedBetterCompression
+ SpeedBestCompression EncoderLevel = zstd.SpeedBestCompression
+)
+
+type ReaderOption = zstd.DOption
+
+var (
+ WithDecoderLowmem = zstd.WithDecoderLowmem
+ WithDecoderConcurrency = zstd.WithDecoderConcurrency
+ WithDecoderMaxMemory = zstd.WithDecoderMaxMemory
+ WithDecoderDicts = zstd.WithDecoderDicts
+ WithDecoderDictRaw = zstd.WithDecoderDictRaw
+ WithDecoderMaxWindow = zstd.WithDecoderMaxWindow
+ WithDecodeAllCapLimit = zstd.WithDecodeAllCapLimit
+ WithDecodeBuffersBelow = zstd.WithDecodeBuffersBelow
+ IgnoreChecksum = zstd.IgnoreChecksum
+)
diff --git a/modules/zstd/zstd.go b/modules/zstd/zstd.go
new file mode 100644
index 0000000..d224944
--- /dev/null
+++ b/modules/zstd/zstd.go
@@ -0,0 +1,163 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package zstd provides a high-level API for reading and writing zstd-compressed data.
+// It supports both regular and seekable zstd streams.
+// It's not a new wheel, but a wrapper around the zstd and zstd-seekable-format-go packages.
+package zstd
+
+import (
+ "errors"
+ "io"
+
+ seekable "github.com/SaveTheRbtz/zstd-seekable-format-go/pkg"
+ "github.com/klauspost/compress/zstd"
+)
+
+type Writer zstd.Encoder
+
+var _ io.WriteCloser = (*Writer)(nil)
+
+// NewWriter returns a new zstd writer.
+func NewWriter(w io.Writer, opts ...WriterOption) (*Writer, error) {
+ zstdW, err := zstd.NewWriter(w, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return (*Writer)(zstdW), nil
+}
+
+func (w *Writer) Write(p []byte) (int, error) {
+ return (*zstd.Encoder)(w).Write(p)
+}
+
+func (w *Writer) Close() error {
+ return (*zstd.Encoder)(w).Close()
+}
+
+type Reader zstd.Decoder
+
+var _ io.ReadCloser = (*Reader)(nil)
+
+// NewReader returns a new zstd reader.
+func NewReader(r io.Reader, opts ...ReaderOption) (*Reader, error) {
+ zstdR, err := zstd.NewReader(r, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return (*Reader)(zstdR), nil
+}
+
+func (r *Reader) Read(p []byte) (int, error) {
+ return (*zstd.Decoder)(r).Read(p)
+}
+
+func (r *Reader) Close() error {
+ (*zstd.Decoder)(r).Close() // no error returned
+ return nil
+}
+
+type SeekableWriter struct {
+ buf []byte
+ n int
+ w seekable.Writer
+}
+
+var _ io.WriteCloser = (*SeekableWriter)(nil)
+
+// NewSeekableWriter returns a zstd writer to compress data to seekable format.
+// blockSize is an important parameter, it should be decided according to the actual business requirements.
+// If it's too small, the compression ratio could be very bad, even no compression at all.
+// If it's too large, it could cost more traffic when reading the data partially from underlying storage.
+func NewSeekableWriter(w io.Writer, blockSize int, opts ...WriterOption) (*SeekableWriter, error) {
+ zstdW, err := zstd.NewWriter(nil, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ seekableW, err := seekable.NewWriter(w, zstdW)
+ if err != nil {
+ return nil, err
+ }
+
+ return &SeekableWriter{
+ buf: make([]byte, blockSize),
+ w: seekableW,
+ }, nil
+}
+
+func (w *SeekableWriter) Write(p []byte) (int, error) {
+ written := 0
+ for len(p) > 0 {
+ n := copy(w.buf[w.n:], p)
+ w.n += n
+ written += n
+ p = p[n:]
+
+ if w.n == len(w.buf) {
+ if _, err := w.w.Write(w.buf); err != nil {
+ return written, err
+ }
+ w.n = 0
+ }
+ }
+ return written, nil
+}
+
+func (w *SeekableWriter) Close() error {
+ if w.n > 0 {
+ if _, err := w.w.Write(w.buf[:w.n]); err != nil {
+ return err
+ }
+ }
+ return w.w.Close()
+}
+
+type SeekableReader struct {
+ r seekable.Reader
+ c func() error
+}
+
+var _ io.ReadSeekCloser = (*SeekableReader)(nil)
+
+// NewSeekableReader returns a zstd reader to decompress data from seekable format.
+func NewSeekableReader(r io.ReadSeeker, opts ...ReaderOption) (*SeekableReader, error) {
+ zstdR, err := zstd.NewReader(nil, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ seekableR, err := seekable.NewReader(r, zstdR)
+ if err != nil {
+ return nil, err
+ }
+
+ ret := &SeekableReader{
+ r: seekableR,
+ }
+ if closer, ok := r.(io.Closer); ok {
+ ret.c = closer.Close
+ }
+
+ return ret, nil
+}
+
+func (r *SeekableReader) Read(p []byte) (int, error) {
+ return r.r.Read(p)
+}
+
+func (r *SeekableReader) Seek(offset int64, whence int) (int64, error) {
+ return r.r.Seek(offset, whence)
+}
+
+func (r *SeekableReader) Close() error {
+ return errors.Join(
+ func() error {
+ if r.c != nil {
+ return r.c()
+ }
+ return nil
+ }(),
+ r.r.Close(),
+ )
+}
diff --git a/modules/zstd/zstd_test.go b/modules/zstd/zstd_test.go
new file mode 100644
index 0000000..9284ab0
--- /dev/null
+++ b/modules/zstd/zstd_test.go
@@ -0,0 +1,304 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package zstd
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWriterReader(t *testing.T) {
+ testData := prepareTestData(t, 15_000_000)
+
+ result := bytes.NewBuffer(nil)
+
+ t.Run("regular", func(t *testing.T) {
+ result.Reset()
+ writer, err := NewWriter(result)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ reader, err := NewReader(result)
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+
+ t.Run("with options", func(t *testing.T) {
+ result.Reset()
+ writer, err := NewWriter(result, WithEncoderLevel(SpeedBestCompression))
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ reader, err := NewReader(result, WithDecoderLowmem(true))
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+}
+
+func TestSeekableWriterReader(t *testing.T) {
+ testData := prepareTestData(t, 15_000_000)
+
+ result := bytes.NewBuffer(nil)
+
+ t.Run("regular", func(t *testing.T) {
+ result.Reset()
+ blockSize := 100_000
+
+ writer, err := NewSeekableWriter(result, blockSize)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+
+ t.Run("seek read", func(t *testing.T) {
+ result.Reset()
+ blockSize := 100_000
+
+ writer, err := NewSeekableWriter(result, blockSize)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ assertReader := &assertReadSeeker{r: bytes.NewReader(result.Bytes())}
+
+ reader, err := NewSeekableReader(assertReader)
+ require.NoError(t, err)
+
+ _, err = reader.Seek(10_000_000, io.SeekStart)
+ require.NoError(t, err)
+
+ data := make([]byte, 1000)
+ _, err = io.ReadFull(reader, data)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData[10_000_000:10_000_000+1000], data)
+
+ // Should seek 3 times,
+ // the first two times are for getting the index,
+ // and the third time is for reading the data.
+ assert.Equal(t, 3, assertReader.SeekTimes)
+ // Should read less than 2 blocks,
+ // even if the compression ratio is not good and the data is not in the same block.
+ assert.Less(t, assertReader.ReadBytes, blockSize*2)
+ // Should close the underlying reader if it is Closer.
+ assert.True(t, assertReader.Closed)
+ })
+
+ t.Run("tidy data", func(t *testing.T) {
+ testData := prepareTestData(t, 1000) // data size is less than a block
+
+ result.Reset()
+ blockSize := 100_000
+
+ writer, err := NewSeekableWriter(result, blockSize)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+
+ t.Run("tidy block", func(t *testing.T) {
+ result.Reset()
+ blockSize := 100
+
+ writer, err := NewSeekableWriter(result, blockSize)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+ // A too small block size will cause a bad compression rate,
+ // even the compressed data is larger than the original data.
+ assert.Greater(t, result.Len(), len(testData))
+
+ reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+
+ t.Run("compatible reader", func(t *testing.T) {
+ result.Reset()
+ blockSize := 100_000
+
+ writer, err := NewSeekableWriter(result, blockSize)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ // It should be able to read the data with a regular reader.
+ reader, err := NewReader(bytes.NewReader(result.Bytes()))
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(reader)
+ require.NoError(t, err)
+ require.NoError(t, reader.Close())
+
+ assert.Equal(t, testData, data)
+ })
+
+ t.Run("wrong reader", func(t *testing.T) {
+ result.Reset()
+
+ // Use a regular writer to compress the data.
+ writer, err := NewWriter(result)
+ require.NoError(t, err)
+
+ _, err = io.Copy(writer, bytes.NewReader(testData))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
+
+ // But use a seekable reader to read the data, it should fail.
+ _, err = NewSeekableReader(bytes.NewReader(result.Bytes()))
+ require.Error(t, err)
+ })
+}
+
+// prepareTestData prepares test data to test compression.
+// Random data is not suitable for testing compression,
+// so it collects code files from the project to get enough data.
+func prepareTestData(t *testing.T, size int) []byte {
+ // .../gitea/modules/zstd
+ dir, err := os.Getwd()
+ require.NoError(t, err)
+ // .../gitea/
+ dir = filepath.Join(dir, "../../")
+
+ textExt := []string{".go", ".tmpl", ".ts", ".yml", ".css"} // add more if not enough data collected
+ isText := func(info os.FileInfo) bool {
+ if info.Size() == 0 {
+ return false
+ }
+ for _, ext := range textExt {
+ if strings.HasSuffix(info.Name(), ext) {
+ return true
+ }
+ }
+ return false
+ }
+
+ ret := make([]byte, size)
+ n := 0
+ count := 0
+
+ queue := []string{dir}
+ for len(queue) > 0 && n < size {
+ file := queue[0]
+ queue = queue[1:]
+ info, err := os.Stat(file)
+ require.NoError(t, err)
+ if info.IsDir() {
+ entries, err := os.ReadDir(file)
+ require.NoError(t, err)
+ for _, entry := range entries {
+ queue = append(queue, filepath.Join(file, entry.Name()))
+ }
+ continue
+ }
+ if !isText(info) { // text file only
+ continue
+ }
+ data, err := os.ReadFile(file)
+ require.NoError(t, err)
+ n += copy(ret[n:], data)
+ count++
+ }
+
+ if n < size {
+ require.Failf(t, "Not enough data", "Only %d bytes collected from %d files", n, count)
+ }
+ return ret
+}
+
+type assertReadSeeker struct {
+ r io.ReadSeeker
+ SeekTimes int
+ ReadBytes int
+ Closed bool
+}
+
+func (a *assertReadSeeker) Read(p []byte) (int, error) {
+ n, err := a.r.Read(p)
+ a.ReadBytes += n
+ return n, err
+}
+
+func (a *assertReadSeeker) Seek(offset int64, whence int) (int64, error) {
+ a.SeekTimes++
+ return a.r.Seek(offset, whence)
+}
+
+func (a *assertReadSeeker) Close() error {
+ a.Closed = true
+ return nil
+}