├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── activitypub └── keys.go ├── activitystreams ├── activity.go ├── attachment.go ├── attachment_test.go ├── data.go ├── person.go └── tag.go ├── auth ├── auth.go ├── pass.go └── pass_test.go ├── bots ├── README.md ├── bots.go ├── bots_test.go └── findBots.sh ├── category ├── category.go ├── tags.go └── tags_test.go ├── converter ├── json.go └── sql.go ├── data ├── data.go └── data_test.go ├── errors ├── general.go └── snapas.go ├── go.mod ├── go.sum ├── i18n └── rtl.go ├── id ├── random.go └── random_test.go ├── l10n ├── phrases.go ├── phrases_ar.go ├── phrases_cs.go ├── phrases_da.go ├── phrases_de.go ├── phrases_el.go ├── phrases_eo.go ├── phrases_es.go ├── phrases_eu.go ├── phrases_fa.go ├── phrases_fr.go ├── phrases_gl.go ├── phrases_he.go ├── phrases_hu.go ├── phrases_it.go ├── phrases_ja.go ├── phrases_ko.go ├── phrases_lt.go ├── phrases_mk.go ├── phrases_nl.go ├── phrases_pl.go ├── phrases_pt.go ├── phrases_ro.go ├── phrases_ru.go ├── phrases_sk.go ├── phrases_sv.go ├── phrases_tg.go ├── phrases_tr.go ├── phrases_zh.go └── strings.go ├── log └── log.go ├── logger ├── logger.go └── logger_test.go ├── memo └── memo.go ├── passgen ├── passgen.go └── wordish.go ├── posts ├── parse.go ├── parse_test.go └── render.go ├── query └── builder.go ├── silobridge └── silobridge.go ├── stringmanip ├── runes.go └── strings.go └── tags └── tags.go /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | notifications: 4 | email: false 5 | 6 | go: 7 | - "1.10" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WriteFreely web core 2 | ==================== 3 | [![GoDoc](https://godoc.org/github.com/writeas/web-core?status.svg)](https://godoc.org/github.com/writeas/web-core) 4 | [![Build Status](https://travis-ci.org/writeas/web-core.svg)](https://travis-ci.org/writeas/web-core) 5 | [![#writefreely on freenode](https://img.shields.io/badge/freenode-%23writefreely-blue.svg)](http://webchat.freenode.net/?channels=writefreely) 6 | [![Discuss on our forum](https://img.shields.io/discourse/https/discuss.write.as/users.svg?label=forum)](https://discuss.write.as/c/development) 7 | 8 | web-core holds components of the [WriteFreely](https://writefreely.org) web application, and is shared with other [Write.as](https://write.as) products, like [Snap.as](https://snap.as). 9 | -------------------------------------------------------------------------------- /activitypub/keys.go: -------------------------------------------------------------------------------- 1 | package activitypub 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "github.com/writeas/openssl-go" 10 | "log" 11 | ) 12 | 13 | const keyBitSize = 2048 14 | 15 | // GenerateKeys creates an RSA keypair and returns the public and private key, 16 | // in that order. 17 | func GenerateKeys() (pubPEM []byte, privPEM []byte) { 18 | var err error 19 | privPEM, err = openssl.Call(nil, "genrsa", fmt.Sprintf("%d", keyBitSize)) 20 | if err != nil { 21 | log.Printf("Unable to generate private key: %v", err) 22 | return nil, nil 23 | } 24 | 25 | pubPEM, err = openssl.Call(privPEM, "rsa", "-in", "/dev/stdin", "-pubout") 26 | if err != nil { 27 | log.Printf("Unable to get public key: %v", err) 28 | return nil, nil 29 | } 30 | return 31 | } 32 | 33 | func parsePrivateKey(der []byte) (crypto.PrivateKey, error) { 34 | if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { 35 | return key, nil 36 | } 37 | if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { 38 | switch key := key.(type) { 39 | case *rsa.PrivateKey: 40 | return key, nil 41 | default: 42 | return nil, fmt.Errorf("found unknown private key type in PKCS#8 wrapping") 43 | } 44 | } 45 | if key, err := x509.ParseECPrivateKey(der); err == nil { 46 | return key, nil 47 | } 48 | 49 | return nil, fmt.Errorf("failed to parse private key") 50 | } 51 | 52 | func parsePublicKey(der []byte) (crypto.PublicKey, error) { 53 | if key, err := x509.ParsePKCS1PublicKey(der); err == nil { 54 | return key, nil 55 | } 56 | if key, err := x509.ParsePKIXPublicKey(der); err == nil { 57 | switch key := key.(type) { 58 | case *rsa.PublicKey: 59 | return key, nil 60 | default: 61 | return nil, fmt.Errorf("found unknown public key type in PKIX wrapping") 62 | } 63 | } 64 | 65 | return nil, fmt.Errorf("failed to parse public key") 66 | } 67 | 68 | // DecodePrivateKey encodes public and private key to PEM format, returning 69 | // them in that order. 70 | func DecodePrivateKey(k []byte) (crypto.PrivateKey, error) { 71 | block, _ := pem.Decode(k) 72 | if block == nil || (block.Type != "RSA PRIVATE KEY" && block.Type != "PRIVATE KEY") { 73 | return nil, fmt.Errorf("failed to decode PEM block containing private key, type %s", block.Type) 74 | } 75 | 76 | return parsePrivateKey(block.Bytes) 77 | } 78 | 79 | // DecodePublicKey decodes public keys 80 | func DecodePublicKey(k []byte) (crypto.PublicKey, error) { 81 | block, _ := pem.Decode(k) 82 | if block == nil || block.Type != "PUBLIC KEY" { 83 | if block != nil { 84 | return nil, fmt.Errorf("failed to decode PEM block containing public key. type: %v", block.Type) 85 | } else { 86 | return nil, fmt.Errorf("failed to decode PEM block containing public key.") 87 | } 88 | } 89 | 90 | return parsePublicKey(block.Bytes) 91 | } 92 | -------------------------------------------------------------------------------- /activitystreams/activity.go: -------------------------------------------------------------------------------- 1 | // Package activitystreams provides all the basic ActivityStreams 2 | // implementation needed for Write.as. 3 | package activitystreams 4 | 5 | import ( 6 | "time" 7 | ) 8 | 9 | const ( 10 | Namespace = "https://www.w3.org/ns/activitystreams" 11 | toPublic = "https://www.w3.org/ns/activitystreams#Public" 12 | ) 13 | 14 | var Extensions = map[string]string{} 15 | 16 | // Activity describes actions that have either already occurred, are in the 17 | // process of occurring, or may occur in the future. 18 | type Activity struct { 19 | BaseObject 20 | Actor string `json:"actor"` 21 | Published time.Time `json:"published,omitempty"` 22 | Updated *time.Time `json:"updated,omitempty"` 23 | To []string `json:"to,omitempty"` 24 | CC []string `json:"cc,omitempty"` 25 | Object *Object `json:"object"` 26 | } 27 | 28 | type FollowActivity struct { 29 | BaseObject 30 | Actor string `json:"actor"` 31 | Published time.Time `json:"published,omitempty"` 32 | To []string `json:"to,omitempty"` 33 | CC []string `json:"cc,omitempty"` 34 | Object string `json:"object"` 35 | } 36 | 37 | // NewCreateActivity builds a basic Create activity that includes the given 38 | // Object and the Object's AttributedTo property as the Actor. 39 | func NewCreateActivity(o *Object) *Activity { 40 | a := Activity{ 41 | BaseObject: BaseObject{ 42 | Context: []interface{}{ 43 | Namespace, 44 | Extensions, 45 | }, 46 | ID: o.ID, 47 | Type: "Create", 48 | }, 49 | Actor: o.AttributedTo, 50 | Object: o, 51 | Published: o.Published, 52 | } 53 | return &a 54 | } 55 | 56 | // NewUpdateActivity builds a basic Update activity that includes the given 57 | // Object and the Object's AttributedTo property as the Actor. 58 | func NewUpdateActivity(o *Object) *Activity { 59 | a := Activity{ 60 | BaseObject: BaseObject{ 61 | Context: []interface{}{ 62 | Namespace, 63 | Extensions, 64 | }, 65 | ID: o.ID, 66 | Type: "Update", 67 | }, 68 | Actor: o.AttributedTo, 69 | Object: o, 70 | Published: o.Published, 71 | } 72 | if o.Updated != nil && !o.Updated.IsZero() { 73 | a.Updated = o.Updated 74 | } 75 | return &a 76 | } 77 | 78 | // NewDeleteActivity builds a basic Delete activity that includes the given 79 | // Object and the Object's AttributedTo property as the Actor. 80 | func NewDeleteActivity(o *Object) *Activity { 81 | a := Activity{ 82 | BaseObject: BaseObject{ 83 | Context: []interface{}{ 84 | Namespace, 85 | }, 86 | ID: o.ID, 87 | Type: "Delete", 88 | }, 89 | Actor: o.AttributedTo, 90 | Object: o, 91 | } 92 | return &a 93 | } 94 | 95 | // NewFollowActivity builds a basic Follow activity. 96 | func NewFollowActivity(actorIRI, followeeIRI string) *FollowActivity { 97 | a := FollowActivity{ 98 | BaseObject: BaseObject{ 99 | Context: []interface{}{ 100 | Namespace, 101 | }, 102 | Type: "Follow", 103 | }, 104 | Actor: actorIRI, 105 | Object: followeeIRI, 106 | } 107 | return &a 108 | } 109 | 110 | // Object is the primary base type for the Activity Streams vocabulary. 111 | type Object struct { 112 | BaseObject 113 | Published time.Time `json:"published,omitempty"` 114 | Updated *time.Time `json:"updated,omitempty"` 115 | Summary *string `json:"summary,omitempty"` 116 | InReplyTo *string `json:"inReplyTo,omitempty"` 117 | URL string `json:"url"` 118 | AttributedTo string `json:"attributedTo,omitempty"` 119 | To []string `json:"to,omitempty"` 120 | CC []string `json:"cc,omitempty"` 121 | Name string `json:"name,omitempty"` 122 | Content string `json:"content,omitempty"` 123 | ContentMap map[string]string `json:"contentMap,omitempty"` 124 | Tag []Tag `json:"tag,omitempty"` 125 | Attachment []Attachment `json:"attachment,omitempty"` 126 | Preview *Object `json:"preview,omitempty"` 127 | 128 | // Person 129 | Inbox string `json:"inbox,omitempty"` 130 | Outbox string `json:"outbox,omitempty"` 131 | Following string `json:"following,omitempty"` 132 | Followers string `json:"followers,omitempty"` 133 | PreferredUsername string `json:"preferredUsername,omitempty"` 134 | Icon *Image `json:"icon,omitempty"` 135 | PublicKey *PublicKey `json:"publicKey,omitempty"` 136 | Endpoints *Endpoints `json:"endpoints,omitempty"` 137 | 138 | // Extensions 139 | // NOTE: add extensions here 140 | } 141 | 142 | // NewNoteObject creates a basic Note object that includes the public 143 | // namespace in IRIs it's addressed to. 144 | func NewNoteObject() *Object { 145 | o := Object{ 146 | BaseObject: BaseObject{ 147 | Type: "Note", 148 | }, 149 | To: []string{ 150 | toPublic, 151 | }, 152 | } 153 | return &o 154 | } 155 | 156 | // NewArticleObject creates a basic Article object that includes the public 157 | // namespace in IRIs it's addressed to. 158 | func NewArticleObject() *Object { 159 | o := Object{ 160 | BaseObject: BaseObject{ 161 | Type: "Article", 162 | }, 163 | To: []string{ 164 | toPublic, 165 | }, 166 | } 167 | return &o 168 | } 169 | 170 | // NewPersonObject creates a basic Person object. 171 | func NewPersonObject() *Object { 172 | o := Object{ 173 | BaseObject: BaseObject{ 174 | Type: "Person", 175 | }, 176 | } 177 | return &o 178 | } 179 | -------------------------------------------------------------------------------- /activitystreams/attachment.go: -------------------------------------------------------------------------------- 1 | package activitystreams 2 | 3 | import ( 4 | "mime" 5 | "strings" 6 | ) 7 | 8 | type Attachment struct { 9 | Type AttachmentType `json:"type"` 10 | URL string `json:"url"` 11 | MediaType string `json:"mediaType"` 12 | Name string `json:"name"` 13 | } 14 | 15 | type AttachmentType string 16 | 17 | const ( 18 | AttachImage AttachmentType = "Image" 19 | AttachDocument AttachmentType = "Document" 20 | ) 21 | 22 | // NewImageAttachment creates a new Attachment from the given URL, setting the 23 | // correct type and automatically detecting the MediaType based on the file 24 | // extension. 25 | func NewImageAttachment(url string) Attachment { 26 | return newAttachment(url, AttachImage) 27 | } 28 | 29 | // NewDocumentAttachment creates a new Attachment from the given URL, setting the 30 | // correct type and automatically detecting the MediaType based on the file 31 | // extension. 32 | func NewDocumentAttachment(url string) Attachment { 33 | return newAttachment(url, AttachDocument) 34 | } 35 | 36 | func newAttachment(url string, attachType AttachmentType) Attachment { 37 | var fileType string 38 | extIdx := strings.LastIndexByte(url, '.') 39 | if extIdx > -1 { 40 | fileType = mime.TypeByExtension(url[extIdx:]) 41 | } 42 | return Attachment{ 43 | Type: attachType, 44 | URL: url, 45 | MediaType: fileType, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /activitystreams/attachment_test.go: -------------------------------------------------------------------------------- 1 | package activitystreams 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestNewImageAttachment(t *testing.T) { 9 | type args struct { 10 | url string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want Attachment 16 | }{ 17 | {name: "good svg", args: args{"https://writefreely.org/img/writefreely.svg"}, want: Attachment{ 18 | Type: "Image", 19 | URL: "https://writefreely.org/img/writefreely.svg", 20 | MediaType: "image/svg+xml", 21 | }}, 22 | {name: "good png", args: args{"https://i.snap.as/12345678.png"}, want: Attachment{ 23 | Type: "Image", 24 | URL: "https://i.snap.as/12345678.png", 25 | MediaType: "image/png", 26 | }}, 27 | {name: "no extension", args: args{"https://i.snap.as/12345678"}, want: Attachment{ 28 | Type: "Image", 29 | URL: "https://i.snap.as/12345678", 30 | MediaType: "", 31 | }}, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | if got := NewImageAttachment(tt.args.url); !reflect.DeepEqual(got, tt.want) { 36 | t.Errorf("NewImageAttachment() = %v, want %v", got, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestNewDocumentAttachment(t *testing.T) { 43 | type args struct { 44 | url string 45 | } 46 | tests := []struct { 47 | name string 48 | args args 49 | want Attachment 50 | }{ 51 | {name: "mp3", args: args{"https://listen.as/matt/abc.mp3"}, want: Attachment{ 52 | Type: "Document", 53 | URL: "https://listen.as/matt/abc.mp3", 54 | MediaType: "audio/mpeg", 55 | }}, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | if got := NewDocumentAttachment(tt.args.url); !reflect.DeepEqual(got, tt.want) { 60 | t.Errorf("NewDocumentAttachment() = %+v, want %+v", got, tt.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /activitystreams/data.go: -------------------------------------------------------------------------------- 1 | package activitystreams 2 | 3 | import "fmt" 4 | 5 | type ( 6 | BaseObject struct { 7 | Context []interface{} `json:"@context,omitempty"` 8 | Type string `json:"type"` 9 | ID string `json:"id"` 10 | } 11 | 12 | PublicKey struct { 13 | ID string `json:"id"` 14 | Owner string `json:"owner"` 15 | PublicKeyPEM string `json:"publicKeyPem"` 16 | privateKey []byte 17 | } 18 | 19 | Endpoints struct { 20 | SharedInbox string `json:"sharedInbox,omitempty"` 21 | } 22 | 23 | Image struct { 24 | Type string `json:"type"` 25 | MediaType string `json:"mediaType"` 26 | URL string `json:"url"` 27 | } 28 | ) 29 | 30 | type OrderedCollection struct { 31 | BaseObject 32 | TotalItems int `json:"totalItems"` 33 | First string `json:"first"` 34 | Last string `json:"last,omitempty"` 35 | } 36 | 37 | func NewOrderedCollection(accountRoot, collType string, items int) *OrderedCollection { 38 | oc := OrderedCollection{ 39 | BaseObject: BaseObject{ 40 | Context: []interface{}{ 41 | Namespace, 42 | }, 43 | ID: accountRoot + "/" + collType, 44 | Type: "OrderedCollection", 45 | }, 46 | First: accountRoot + "/" + collType + "?page=1", 47 | TotalItems: items, 48 | } 49 | return &oc 50 | } 51 | 52 | type OrderedCollectionPage struct { 53 | BaseObject 54 | TotalItems int `json:"totalItems"` 55 | PartOf string `json:"partOf"` 56 | Next string `json:"next,omitempty"` 57 | Prev string `json:"prev,omitempty"` 58 | OrderedItems []interface{} `json:"orderedItems,omitempty"` 59 | } 60 | 61 | func NewOrderedCollectionPage(accountRoot, collType string, items, page int) *OrderedCollectionPage { 62 | ocp := OrderedCollectionPage{ 63 | BaseObject: BaseObject{ 64 | Context: []interface{}{ 65 | Namespace, 66 | }, 67 | ID: fmt.Sprintf("%s/%s?page=%d", accountRoot, collType, page), 68 | Type: "OrderedCollectionPage", 69 | }, 70 | TotalItems: items, 71 | PartOf: accountRoot + "/" + collType, 72 | Next: fmt.Sprintf("%s/%s?page=%d", accountRoot, collType, page+1), 73 | } 74 | return &ocp 75 | } 76 | -------------------------------------------------------------------------------- /activitystreams/person.go: -------------------------------------------------------------------------------- 1 | package activitystreams 2 | 3 | type Person struct { 4 | BaseObject 5 | Inbox string `json:"inbox"` 6 | Outbox string `json:"outbox"` 7 | PreferredUsername string `json:"preferredUsername"` 8 | URL string `json:"url"` 9 | Name string `json:"name"` 10 | Icon Image `json:"icon"` 11 | Following string `json:"following"` 12 | Followers string `json:"followers"` 13 | Summary string `json:"summary"` 14 | PublicKey PublicKey `json:"publicKey"` 15 | Endpoints Endpoints `json:"endpoints"` 16 | } 17 | 18 | func NewPerson(accountRoot string) *Person { 19 | p := Person{ 20 | BaseObject: BaseObject{ 21 | Type: "Person", 22 | Context: []interface{}{ 23 | Namespace, 24 | }, 25 | ID: accountRoot, 26 | }, 27 | Following: accountRoot + "/following", 28 | Followers: accountRoot + "/followers", 29 | Inbox: accountRoot + "/inbox", 30 | Outbox: accountRoot + "/outbox", 31 | } 32 | 33 | return &p 34 | } 35 | 36 | func (p *Person) AddPubKey(k []byte) { 37 | p.Context = append(p.Context, "https://w3id.org/security/v1") 38 | p.PublicKey = PublicKey{ 39 | ID: p.ID + "#main-key", 40 | Owner: p.ID, 41 | PublicKeyPEM: string(k), 42 | } 43 | } 44 | 45 | func (p *Person) SetPrivKey(k []byte) { 46 | p.PublicKey.privateKey = k 47 | } 48 | 49 | func (p *Person) GetPrivKey() []byte { 50 | return p.PublicKey.privateKey 51 | } 52 | -------------------------------------------------------------------------------- /activitystreams/tag.go: -------------------------------------------------------------------------------- 1 | package activitystreams 2 | 3 | type Tag struct { 4 | Type TagType `json:"type"` 5 | HRef string `json:"href"` 6 | Name string `json:"name"` 7 | } 8 | 9 | type TagType string 10 | 11 | const ( 12 | TagHashtag TagType = "Hashtag" 13 | TagMention TagType = "Mention" 14 | ) 15 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | uuid "github.com/gofrs/uuid" 5 | "github.com/writeas/web-core/log" 6 | "strings" 7 | ) 8 | 9 | // GetToken parses out the user token from either an Authorization header or simply passed in. 10 | func GetToken(header string) []byte { 11 | var accessToken []byte 12 | token := header 13 | if len(header) > 0 { 14 | f := strings.Fields(header) 15 | if len(f) == 2 && f[0] == "Token" { 16 | token = f[1] 17 | } 18 | } 19 | t, err := uuid.FromString(token) 20 | if err != nil { 21 | log.Error("Couldn't parseHex on '%s': %v", accessToken, err) 22 | } else { 23 | accessToken = t[:] 24 | } 25 | return accessToken 26 | } 27 | 28 | // GetHeaderToken parses out the user token from an Authorization header. 29 | func GetHeaderToken(header string) []byte { 30 | var accessToken []byte 31 | if len(header) > 0 { 32 | f := strings.Fields(header) 33 | if len(f) == 2 && f[0] == "Token" { 34 | t, err := uuid.FromString(f[1]) 35 | if err != nil { 36 | log.Error("Couldn't parseHex on '%s': %v", accessToken, err) 37 | } else { 38 | accessToken = t[:] 39 | } 40 | } 41 | } 42 | return accessToken 43 | } 44 | -------------------------------------------------------------------------------- /auth/pass.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | func clear(b []byte) { 6 | for i := 0; i < len(b); i++ { 7 | b[i] = 0 8 | } 9 | } 10 | 11 | func HashPass(password []byte) ([]byte, error) { 12 | // Clear memory where plaintext password was stored. 13 | // http://stackoverflow.com/questions/18545676/golang-app-engine-securely-hashing-a-users-password#comment36585613_19828153 14 | defer clear(password) 15 | // Return hash 16 | return bcrypt.GenerateFromPassword(password, 12) 17 | } 18 | 19 | func Authenticated(hash, pass []byte) bool { 20 | return bcrypt.CompareHashAndPassword(hash, pass) == nil 21 | } 22 | -------------------------------------------------------------------------------- /auth/pass_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "testing" 4 | 5 | const pass = "password" 6 | 7 | var hash []byte 8 | 9 | func TestHash(t *testing.T) { 10 | var err error 11 | hash, err = HashPass([]byte(pass)) 12 | if err != nil { 13 | t.Error("Password hash failed.") 14 | } 15 | } 16 | 17 | func TestAuth(t *testing.T) { 18 | if !Authenticated(hash, []byte(pass)) { 19 | t.Error("Didn't authenticate.") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bots/README.md: -------------------------------------------------------------------------------- 1 | bots 2 | ==== 3 | 4 | This package helps the backend determine which clients are bots or crawlers. 5 | 6 | ## Write.as Usage 7 | 8 | This is used to prevent certain things when viewing posts, like incrementing the view count. 9 | -------------------------------------------------------------------------------- /bots/bots.go: -------------------------------------------------------------------------------- 1 | // This package helps the backend determine which clients are bots or crawlers. 2 | // In Write.as, this is used to prevent certain things when viewing posts, like 3 | // incrementing the view count. 4 | package bots 5 | 6 | import "strings" 7 | 8 | var bots = map[string]bool{ 9 | "ABACHOBot/8.14 (Windows NT 6.1 1.5; ko;)": true, 10 | "AcademicBotRTU (https://academicbot.rtu.lv; mailto:caps@rtu.lv)": true, 11 | "AddThis.com robot tech.support@clearspring.com": true, 12 | "AdsBot-Google (+http://www.google.com/adsbot.html)": true, 13 | "AdsTxtCrawlerTP/1.2": true, 14 | "Alwyzbot/1.0": true, 15 | "Amazon-Advertising-ad-standards-bot/1.0": true, 16 | "Apple Color Emoji": true, 17 | "AwarioRssBot/1.0 (+https://awario.com/bots.html; bots@awario.com)": true, 18 | "AwarioSmartBot/1.0 (+https://awario.com/bots.html; bots@awario.com)": true, 19 | "BeeperBot/0 Matrix-Media-Repo/1": true, 20 | "bidswitchbot/1.0": true, 21 | "bitlybot": true, 22 | "BrightEdge Crawler/1.0 (crawler@brightedge.com)": true, 23 | "BSbot 1.1 (monthly copyright check - html/js/css)": true, 24 | "Buzzbot/1.0 (Buzzbot; http://www.buzzstream.com; buzzbot@buzzstream.com)": true, 25 | "CheckMarkNetwork/1.0 (+http://www.checkmarknetwork.com/spider.html)": true, 26 | "Chrome/41.0.2272.96 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true, 27 | "cis455crawler": true, 28 | "Clickagy Intelligence Bot v2": true, 29 | "COMODO SSL Checker": true, 30 | "companyspotter/1.0.0.0 (robot@companyspotter.com)": true, 31 | "crawler_eb_germany_2.0": true, 32 | "crawlernutchtest/Nutch-1.9": true, 33 | "CRAZYWEBCRAWLER 0.9.8, http://www.crazywebcrawler.com": true, 34 | "CSS Certificate Spider (http://www.css-security.com/certificatespider/)": true, 35 | "datebot": true, 36 | "DeadYetBot/1.0 (+http://deadyet.lol)": true, 37 | "Deskyobot/1.0 (+https://www.deskyo.com/bot)": true, 38 | "DingTalkBot-LinkService/1.0 (+https://open-doc.dingtalk.com/microapp/faquestions/ftpfeu)": true, 39 | "DisqusAdstxtCrawler/0.1 (+https://help.disqus.com/en/articles/1765357-ads-txt-implementation-guide)": true, 40 | "dj-research/Nutch-1.11 (analytics@@demandjump.com)": true, 41 | "DnBCrawler-Analytics": true, 42 | "DoCoMo/2.0 N905i(c100;TB;W24H16) (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)": true, 43 | "Domain Re-Animator Bot (http://domainreanimator.com) - support@domainreanimator.com": true, 44 | "DomainCrawler/1.0": true, 45 | "DomainStatsBot/1.0 (https://domainstats.com/pages/our-bot)": true, 46 | "drinespider/Nutch-1.19 (D-RINE Spider; www.d-rine.com/search/about; www.d-rine.com/contact)": true, 47 | "DuckDuckBot-Https/1.1; (+https://duckduckgo.com/duckduckbot)": true, 48 | "ExactSeekCrawler/1.0": true, 49 | "EyeMonIT Uptime Bot Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36": true, 50 | "FediCrawl/1.0": true, 51 | "fedistatsCrawler/1.0": true, 52 | "FeedlyBot/1.0 (http://feedly.com)": true, 53 | "GG PeekBot 2.0 ( https://www.gg.pl/ https://www.gg.pl/info/praca/ )": true, 54 | "Gigabot/1.0": true, 55 | "GNUsocialBot 2.0.1-beta0 - https://gnusocial.rocks": true, 56 | "Go 1.1 package http": true, 57 | "Go-http-client/2.0": true, 58 | "Google-Adwords-Instant (+http://www.google.com/adsbot.html)": true, 59 | "Google-Display-Ads-Bot": true, 60 | "Googlebot": true, 61 | "Greppr Bot 1.0/Nutch-1.19": true, 62 | "GroupMeBot/1.0": true, 63 | "GuzzleHttp/7": true, 64 | "Gwene/1.0 (The gwene.org rss-to-news gateway) Googlebot": true, 65 | "HubSpot Connect 2.0 (http://dev.hubspot.com/) (namespace: hs_web_crawler) - RoadsModelsJobs-hubspot-53-domain-web-crawl": true, 66 | "ia_archiver (+http://www.alexa.com/site/help/webmasters; crawler@alexa.com)": true, 67 | "IABot/2.0 (+https://meta.wikimedia.org/wiki/InternetArchiveBot/FAQ_for_sysadmins) (Checking if link from Wikipedia is broken and needs removal)": true, 68 | "IonCrawl (https://www.ionos.de/terms-gtc/faq-crawler-en/)": true, 69 | "LightspeedSystemsCrawler Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)": true, 70 | "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)": true, 71 | "LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)": true, 72 | "LivelapBot/0.2 (http://site.livelap.com/crawler)": true, 73 | "LSSRocketCrawler/1.0 LightspeedSystems": true, 74 | "magpie-crawler/1.1 (U; Linux amd64; en-GB; +http://www.brandwatch.net)": true, 75 | "Mastodon server indexer": true, 76 | "MBCrawler/1.0 (https://monitorbacklinks.com/robot)": true, 77 | "Mediatoolkitbot (complaints@mediatoolkit.com)": true, 78 | "Mediatoolkitbot (info@mediatoolkit.com)": true, 79 | "Melvil/1.0": true, 80 | "MetaCommentBot; http://metacomment.io/about": true, 81 | "mfibot/1.1 (http://www.mfisoft.ru/analyst/; ; en-RU)": true, 82 | "Minoru's Fediverse Crawler (+https://nodes.fediverse.party)": true, 83 | "mj12bot": true, 84 | "Mozilla/4.0 (compatible; QwertyNetworksBot/1.0; +https://qwertynetworks.com/faq/QwertyNetworksBot)": true, 85 | "Mozilla/4.0 (compatible; Wibybot; https://wiby.me/)": true, 86 | "Mozilla/5.0 (Android 11; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0": true, 87 | "Mozilla/5.0 (compatible) SemanticScholarBot (+https://www.semanticscholar.org/crawler)": true, 88 | "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)": true, 89 | "Mozilla/5.0 (compatible; aiHitBot/2.9; +https://www.aihitdata.com/about)": true, 90 | "Mozilla/5.0 (compatible; alexa site audit/1.0; +http://www.alexa.com/help/webmasters; )": true, 91 | "Mozilla/5.0 (compatible; AmazonAdBot/1.0; +https://adbot.amazon.com)": true, 92 | "Mozilla/5.0 (compatible; archive.org_bot +http://archive.org/details/archive.org_bot) Zeno/0030699 warc/v0.8.32": true, 93 | "Mozilla/5.0 (compatible; Atomseobot/2.0; +http://https://error404.atomseo.com/)": true, 94 | "Mozilla/5.0 (compatible; AwarioBot/1.0; +https://awario.com/bots.html)": true, 95 | "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html": true, 96 | "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)": true, 97 | "Mozilla/5.0 (compatible; BitSightBot/1.0)": true, 98 | "Mozilla/5.0 (compatible; BLEXBot/1.0; +http://webmeup-crawler.com/)": true, 99 | "Mozilla/5.0 (compatible; BomboraBot/1.0; +http://www.bombora.com/bot)": true, 100 | "Mozilla/5.0 (compatible; BuzzSumo; +http://www.buzzsumo.com/bot.html)": true, 101 | "Mozilla/5.0 (compatible; Bytespider; spider-feedback@bytedance.com) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.0.0 Safari/537.36": true, 102 | "Mozilla/5.0 (compatible; coccoc/1.0; +http://help.coccoc.com/)": true, 103 | "Mozilla/5.0 (compatible; coccocbot-image/1.0; +http://help.coccoc.com/searchengine)": true, 104 | "Mozilla/5.0 (compatible; coccocbot-web/1.0; +http://help.coccoc.com/searchengine)": true, 105 | "Mozilla/5.0 (compatible; Cocolyzebot/1.0; https://cocolyze.com/bot)": true, 106 | "Mozilla/5.0 (compatible; DataForSeoBot/1.0; +https://dataforseo.com/dataforseo-bot)": true, 107 | "Mozilla/5.0 (compatible; Discordbot/1.0; +https://discordapp.com)": true, 108 | "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)": true, 109 | "Mozilla/5.0 (compatible; Dmbot/1.1)": true, 110 | "Mozilla/5.0 (compatible; DotBot/1.1; http://www.opensiteexplorer.org/dotbot, help@moz.com)": true, 111 | "Mozilla/5.0 (compatible; DotBot/1.2; +https://opensiteexplorer.org/dotbot; help@moz.com)": true, 112 | "Mozilla/5.0 (compatible; DuckDuckGo-Favicons-Bot/1.0; +http://duckduckgo.com)": true, 113 | "Mozilla/5.0 (compatible; ev-crawler/1.0; +https://headline.com/legal/crawler)": true, 114 | "Mozilla/5.0 (compatible; Exabot/3.0; +http://www.exabot.com/go/robot)": true, 115 | "Mozilla/5.0 (compatible; Feedspotbot/1.0; +http://www.feedspot.com/fs/bot)": true, 116 | "Mozilla/5.0 (compatible; FFZBot/2.4.1; +https://www.frankerfacez.com)": true, 117 | "Mozilla/5.0 (compatible; FFZBot/3.0.0; +https://www.frankerfacez.com)": true, 118 | "Mozilla/5.0 (compatible; Findxbot/1.0; +http://www.findxbot.com)": true, 119 | "Mozilla/5.0 (compatible; Gluten Free Crawler/1.0; +http://glutenfreepleasure.com/)": true, 120 | "Mozilla/5.0 (compatible; GrapeshotCrawler/2.0; +http://www.grapeshot.co.uk/crawler.php)": true, 121 | "Mozilla/5.0 (compatible; heritrix/3.3.0-SNAPSHOT-2014-11-14T15:29:34Z +http://citeseerx.ist.psu.edu/)": true, 122 | "Mozilla/5.0 (compatible; heritrix/3.3.0-SNAPSHOT-20140702-2247 +http://archive.org/details/archive.org_bot)": true, 123 | "Mozilla/5.0 (compatible; Hkfl-Bot/%s +https://hackerfall.com/)": true, 124 | "Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.5 (like Gecko) (Exabot-Thumbnails)": true, 125 | "Mozilla/5.0 (compatible; Kraken/0.1; http://linkfluence.net/; bot@linkfluence.net)": true, 126 | "Mozilla/5.0 (compatible; Linespider/1.1; +https://lin.ee/4dwXkTH)": true, 127 | "Mozilla/5.0 (compatible; linkdexbot/2.0; +http://www.linkdex.com/bots/)": true, 128 | "Mozilla/5.0 (compatible; linkdexbot/2.2; +http://www.linkdex.com/bots/)": true, 129 | "Mozilla/5.0 (compatible; LinkFeatureBot)": true, 130 | "Mozilla/5.0 (compatible; LinkisBot/1.0; bot@linkis.com) (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) Mobile/12H321": true, 131 | "Mozilla/5.0 (compatible; LinkisBot/1.0; bot@linkis.com)": true, 132 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/2.0; +http://go.mail.ru/help/robots)": true, 133 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/Fast/2.0; +https://help.mail.ru/webmaster/indexing/robots)": true, 134 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/Img/2.0; +https://help.mail.ru/webmaster/indexing/robots)": true, 135 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/Robots/2.0; +http://go.mail.ru/help/robots)": true, 136 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/Robots/2.0; +https://help.mail.ru/webmaster/indexing/robots)": true, 137 | "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/Target/2.0; +https://help.mail.ru/webmaster/indexing/robots)": true, 138 | "Mozilla/5.0 (compatible; Lipperhey-Kaus-Australis/5.0; +https://www.lipperhey.com/en/about/)": true, 139 | "Mozilla/5.0 (compatible; meanpathbot/1.0; +http://www.meanpath.com/meanpathbot.html)": true, 140 | "Mozilla/5.0 (compatible; MegaIndex.ru/2.0; +http://megaindex.com/crawler)": true, 141 | "Mozilla/5.0 (compatible; MixrankBot; crawler@mixrank.com)": true, 142 | "Mozilla/5.0 (compatible; MJ12bot/v1.4.2; http://www.majestic12.co.uk/bot.php?+)": true, 143 | "Mozilla/5.0 (compatible; MJ12bot/v1.4.5; http://www.majestic12.co.uk/bot.php?+)": true, 144 | "Mozilla/5.0 (compatible; MJ12bot/v1.4.8; http://mj12bot.com/)": true, 145 | "Mozilla/5.0 (compatible; MojeekBot/0.11; +https://www.mojeek.com/bot.html)": true, 146 | "Mozilla/5.0 (compatible; MojeekBot/0.6; +https://www.mojeek.com/bot.html)": true, 147 | "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)": true, 148 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; abot v1.2.3.1 http://code.google.com/p/abot)": true, 149 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0); 360Spider": true, 150 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0); 360Spider(compatible; HaosouSpider; http://www.haosou.com/help/help_3_2.html)": true, 151 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)": true, 152 | "Mozilla/5.0 (compatible; MSIE or Firefox mutant; not on Windows server;) Daum 4.1": true, 153 | "Mozilla/5.0 (compatible; MSIE or Firefox mutant; not on Windows server;) Daumoa 4.0": true, 154 | "Mozilla/5.0 (compatible; MSIE or Firefox mutant;) Daum 4.1": true, 155 | "Mozilla/5.0 (compatible; NetpeakCheckerBot/3.6; +https://netpeaksoftware.com/checker)": true, 156 | "Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)": true, 157 | "Mozilla/5.0 (compatible; oBot/2.3.1; +http://filterdb.iss.net/crawler/)": true, 158 | "Mozilla/5.0 (compatible; OpenHoseBot/2.1; +http://www.openhose.org/bot.html)": true, 159 | "Mozilla/5.0 (compatible; OpsBot/1.0)": true, 160 | "Mozilla/5.0 (compatible; OptimizationCrawler/0.2; +http://www.domainoptima.com/robot)": true, 161 | "Mozilla/5.0 (compatible; PaperLiBot/2.1; http://support.paper.li/entries/20023257-what-is-paper-li)": true, 162 | "Mozilla/5.0 (compatible; PaperLiBot/2.1; https://support.paper.li/hc/en-us/articles/360006695637-PaperLiBot)": true, 163 | "Mozilla/5.0 (compatible; Pinterestbot/1.0; +http://www.pinterest.com/bot.html)": true, 164 | "Mozilla/5.0 (compatible; PrivacyAwareBot/1.1; +http://www.privacyaware.org)": true, 165 | "Mozilla/5.0 (compatible; proximic; +https://www.comscore.com/Web-Crawler)": true, 166 | "Mozilla/5.0 (compatible; Qwantify-dev/1.0; +https://help.qwant.com/bot/)": true, 167 | "Mozilla/5.0 (compatible; Qwantify-dev10230/1.0; +https://help.qwant.com/bot/)": true, 168 | "Mozilla/5.0 (compatible; Qwantify-dev19924/1.0; +https://help.qwant.com/bot/)": true, 169 | "Mozilla/5.0 (compatible; Qwantify-dev19967/1.0; +https://help.qwant.com/bot/)": true, 170 | "Mozilla/5.0 (compatible; Qwantify-dev2270/1.0; +https://help.qwant.com/bot/)": true, 171 | "Mozilla/5.0 (compatible; Qwantify-dev22709/1.0; +https://help.qwant.com/bot/)": true, 172 | "Mozilla/5.0 (compatible; Qwantify-dev31653/1.0; +https://help.qwant.com/bot/)": true, 173 | "Mozilla/5.0 (compatible; Qwantify-dev4541/1.0; +https://help.qwant.com/bot/)": true, 174 | "Mozilla/5.0 (compatible; Qwantify-dev5115/1.0; +https://help.qwant.com/bot/)": true, 175 | "Mozilla/5.0 (compatible; Qwantify-dev846/1.0; +https://help.qwant.com/bot/)": true, 176 | "Mozilla/5.0 (compatible; Qwantify-worker2994/1.0; +https://help.qwant.com/bot/)": true, 177 | "Mozilla/5.0 (compatible; Qwantify/2.2w; +https://www.qwant.com/)/*": true, 178 | "Mozilla/5.0 (compatible; redditbot/1.0; +http://www.reddit.com/feedback)": true, 179 | "Mozilla/5.0 (compatible; rss2tg bot; +http://komar.in/en/rss2tg_crawler)": true, 180 | "Mozilla/5.0 (compatible; Sadakura; +http://shorf.com/bot.php)": true, 181 | "Mozilla/5.0 (compatible; SearchMySiteBot/1.0; +https://searchmysite.net)": true, 182 | "Mozilla/5.0 (compatible; Semanticbot/1.0; +http://sempi.tech/bot.html)": true, 183 | "Mozilla/5.0 (compatible; SemrushBot-BA; +http://www.semrush.com/bot.html)": true, 184 | "Mozilla/5.0 (compatible; SemrushBot-SI/0.97; +http://www.semrush.com/bot.html)": true, 185 | "Mozilla/5.0 (compatible; SemrushBot/1.1~bl; +http://www.semrush.com/bot.html)": true, 186 | "Mozilla/5.0 (compatible; SemrushBot/1~bl; +http://www.semrush.com/bot.html)": true, 187 | "Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)": true, 188 | "Mozilla/5.0 (compatible; SemrushBot; +http://www.semrush.com/bot.html)": true, 189 | "Mozilla/5.0 (compatible; SEOkicks-Robot; +http://www.seokicks.de/robot.html)": true, 190 | "Mozilla/5.0 (compatible; SEOlyticsCrawler/3.0; +http://crawler.seolytics.net/)": true, 191 | "Mozilla/5.0 (compatible; SeznamBot/3.2; +http://fulltext.sblog.cz/)": true, 192 | "Mozilla/5.0 (compatible; SeznamBot/3.2; +http://napoveda.seznam.cz/en/seznambot-intro/)": true, 193 | "Mozilla/5.0 (compatible; SeznamBot/4.0; +http://napoveda.seznam.cz/seznambot-intro/)": true, 194 | "Mozilla/5.0 (compatible; SiteAuditBot/0.97; +http://www.semrush.com/bot.html)": true, 195 | "Mozilla/5.0 (compatible; spbot/4.4.2; +http://OpenLinkProfiler.org/bot )": true, 196 | "Mozilla/5.0 (compatible; spbot/5.0.1; +http://OpenLinkProfiler.org/bot )": true, 197 | "Mozilla/5.0 (compatible; spbot/5.0.2; +http://OpenLinkProfiler.org/bot )": true, 198 | "Mozilla/5.0 (compatible; spbot/5.0; +http://OpenLinkProfiler.org/bot )": true, 199 | "Mozilla/5.0 (compatible; special_archiver/3.1.1 +http://www.archive.org/details/archive.org_bot)": true, 200 | "Mozilla/5.0 (compatible; startmebot/1.0; +https://start.me/bot)": true, 201 | "Mozilla/5.0 (compatible; SurdotlyBot/1.0; +http://sur.ly/bot.html)": true, 202 | "Mozilla/5.0 (compatible; SynapseMediaProxyBot/0.0.1; https://git.pixie.town/f0x/synapse-media-proxy)": true, 203 | "Mozilla/5.0 (compatible; TestCrawler)": true, 204 | "Mozilla/5.0 (compatible; TrendsmapResolver/0.1)": true, 205 | "Mozilla/5.0 (compatible; uMBot-LN/1.0; mailto: crawling@ubermetrics-technologies.com)": true, 206 | "Mozilla/5.0 (compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)": true, 207 | "Mozilla/5.0 (compatible; WebwikiBot/2.1; +https://www.webwiki.de)": true, 208 | "Mozilla/5.0 (compatible; WellKnownBot/0.1; +https://well-known.dev/about/#bot)": true, 209 | "Mozilla/5.0 (compatible; woriobot +http://worio.com)": true, 210 | "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp) sieve.k8s.crawler-production/1688317128-0": true, 211 | "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)": true, 212 | "Mozilla/5.0 (compatible; YaK/1.0; http://linkfluence.com/; bot@linkfluence.com)": true, 213 | "Mozilla/5.0 (compatible; YandexAccessibilityBot/3.0; +http://yandex.com/bots) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0": true, 214 | "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0": true, 215 | "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)": true, 216 | "Mozilla/5.0 (compatible; YandexFavicons/1.0; +http://yandex.com/bots)": true, 217 | "Mozilla/5.0 (compatible; YandexImages/3.0; +http://yandex.com/bots)": true, 218 | "Mozilla/5.0 (compatible; YandexRenderResourcesBot/1.0; +http://yandex.com/bots) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0": true, 219 | "Mozilla/5.0 (compatible; YandexUserproxy; robot; +http://yandex.com/bots)": true, 220 | "Mozilla/5.0 (compatible; Yeti/1.1; +http://help.naver.com/robots/)": true, 221 | "Mozilla/5.0 (compatible; YetiBOT/0.1b; +http://yetibot.ovh)": true, 222 | "Mozilla/5.0 (compatible; zitebot support [at] zite [dot] com +http://zite.com)": true, 223 | "Mozilla/5.0 (compatible;PetalBot;+https://webmaster.petalsearch.com/site/petalbot)": true, 224 | "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e YisouSpider/5.0 Safari/602.1": true, 225 | "Mozilla/5.0 (iPhone; CPU iPhone OS 15_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Weibo (iPhone14,3__weibo__13.6.3__iphone__os15.1)": true, 226 | "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Weibo (iPhone12,8__weibo__13.6.3__iphone__os15.6)": true, 227 | "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; Applebot/0.3; +http://www.apple.com/go/applebot)": true, 228 | "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; SiteAuditBot/0.97; +http://www.semrush.com/bot.html)": true, 229 | "Mozilla/5.0 (iPhone; CPU iPhone OS 8_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12B410 Safari/600.1.4 (Applebot/0.1; +http://www.apple.com/go/applebot)": true, 230 | "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12F70 Safari/600.1.4 (compatible; Laserlikebot/0.1)": true, 231 | "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1 (compatible; Baiduspider-render/2.0; +http://www.baidu.com/search/spider.html)": true, 232 | "Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1": true, 233 | "Mozilla/5.0 (Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0": true, 234 | "Mozilla/5.0 (Linux x86_64; rv:114.0) Gecko/20100101 Firefox/114.0": true, 235 | "Mozilla/5.0 (Linux; Android 10; BAH3-W59 Build/HUAWEIBAH3-W59; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/92.0.4515.105 Safari/537.36 Weibo (HUAWEI-BAH3-W59__weibo__12.5.2__android__android10)": true, 236 | "Mozilla/5.0 (Linux; Android 10; VCE-AL00 Build/HUAWEIVCE-AL00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/92.0.4515.105 Mobile Safari/537.36 Weibo (HUAWEI-VCE-AL00__weibo__13.6.3__android__android10)": true, 237 | "Mozilla/5.0 (Linux; Android 13; V2244A Build/TP1A.220624.014; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.74 Mobile Safari/537.36V2244A_13_WeiboIntlAndroid_6200": true, 238 | "Mozilla/5.0 (Linux; Android 13; V2302A Build/TP1A.220624.014; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.74 Mobile Safari/537.36 V2302A_13_WeiboIntlAndroid_2612": true, 239 | "Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; https://zhanzhang.toutiao.com/)": true, 240 | "Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)": true, 241 | "Mozilla/5.0 (Linux; Android 5.1.1; A37f) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.74 Mobile Safari/537.36": true, 242 | "Mozilla/5.0 (Linux; Android 6.0.1; vivo 1606 Build/MMB29M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36 VivoBrowser/5.7.0.6": true, 243 | "Mozilla/5.0 (Linux; Android 6.0.1; vivo Y66 Build/MMB29M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36 VivoBrowser/10.9.14.0": true, 244 | "Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot)": true, 245 | "Mozilla/5.0 (Linux;u;Android 4.2.2;zh-cn;) AppleWebKit/534.46 (KHTML,like Gecko) Version/5.1 Mobile Safari/10600.6.3 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)": true, 246 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:42.0) Gecko/20100101 Firefox/42.0": true, 247 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:49.0) Gecko/20100101 Firefox/49.0 (FlipboardProxy/1.2; +http://flipboard.com/browserproxy)": true, 248 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:28.0) Gecko/20100101 Firefox/28.0 (FlipboardProxy/1.1; +http://flipboard.com/browserproxy)": true, 249 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:29.0) Gecko/20100101 Firefox/29.0": true, 250 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Applebot/0.1; +http://www.apple.com/go/applebot)": true, 251 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0": true, 252 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36": true, 253 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36": true, 254 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.7 DuckDuckGo/7 Safari/605.1.15": true, 255 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)": true, 256 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.81 Safari/537.36 Edg/104.0.1293.54; Bot": true, 257 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36 webtru_crawler": true, 258 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_2) AppleWebKit/535.7 (KHTML, like Gecko) Chrome/16.0.912.77 Safari/535.7 AppEngine-Google; (+http://code.google.com/appengine; appid: s~feedly-social)": true, 259 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.114 Safari/537.36": true, 260 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 ISSCyberRiskCrawler/1.1.3": true, 261 | "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-us) AppleWebKit/533.18.1 (KHTML, like Gecko) Version/5.0.2 Safari/533.18.5": true, 262 | "Mozilla/5.0 (Morningscore Bot/1.0)": true, 263 | "Mozilla/5.0 (TweetmemeBot/4.0; +http://datasift.com/bot.html) Gecko/20100101 Firefox/31.0": true, 264 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Safari/537.36 (compatible; Google-Safety; +http://www.google.com/bot.html)": true, 265 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 (compatible; Google-Safety; +http://www.google.com/bot.html)": true, 266 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; trendictionbot0.5.0; trendiction search; http://www.trendiction.de/bot; please let us know of any problems; web at trendiction.com) Gecko/20170101 Firefox/67.0": true, 267 | "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36": true, 268 | "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0": true, 269 | "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko": true, 270 | "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/534.27+ (KHTML, like Gecko) Version/5.0.4 Safari/533.20.27": true, 271 | "Mozilla/5.0 (Windows NT 5.1; rv:33.0) Gecko/20100101 Firefox/33.0": true, 272 | "Mozilla/5.0 (Windows NT 5.1; rv:6.0.2) Gecko/20100101 Firefox/6.0.2": true, 273 | "Mozilla/5.0 (Windows NT 6.1) (compatible; SMTBot/1.0; +http://www.similartech.com/smtbot)": true, 274 | "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1; 360Spider(compatible; HaosouSpider; http://www.haosou.com/help/help_3_2.html)": true, 275 | "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.81 YisouSpider/5.0 Safari/537.36": true, 276 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131 Safari/537.36": true, 277 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.137 Safari/537.36": true, 278 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.86 Safari/537.36": true, 279 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.73 Safari/537.36": true, 280 | "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:21.0) Gecko/20100101 Firefox/21.0": true, 281 | "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:28.0) Gecko/20100101 Firefox/28.0": true, 282 | "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:29.0) Gecko/20100101 Firefox/29.0": true, 283 | "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:19.0) Gecko/20100101 Firefox/19.0": true, 284 | "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131 Safari/537.36": true, 285 | "Mozilla/5.0 (Windows NT 6.3; WOW64; rv:39.0) Gecko/20100101 Firefox/39.0": true, 286 | "Mozilla/5.0 (Windows; Crawler; U; Windows NT 6.0; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7 (.NET CLR 3.5.30729)": true, 287 | "Mozilla/5.0 (Windows; U; Windows NT 5.0; de-DE; rv:1.6) Gecko/20040206 Firefox/0.8": true, 288 | "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.116 Safari/537.36 HubSpot Webcrawler": true, 289 | "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8b4) Gecko/20050908 Firefox/1.4": true, 290 | "Mozilla/5.0 (Windows; U; Windows NT 5.1; en; rv:1.9.0.13) Gecko/2009073022 Firefox/3.5.2 (.NET CLR 3.5.30729) SurveyBot/2.3 (DomainTools)": true, 291 | "Mozilla/5.0 (Windows; U; Windows NT 5.1; ja; rv:1.8.0.10) Gecko/20070216 Firefox/1.5.0.10": true, 292 | "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-GB; rv:1.0; trendictionbot0.5.0; trendiction search; http://www.trendiction.de/bot; please let us know of any problems; web at trendiction.com) Gecko/20071127 Firefox/3.0.0.11": true, 293 | "Mozilla/5.0 (Windows; U; WinNT4.0; de-DE; rv:1.7.6) Gecko/20050226 Firefox/1.0.1": true, 294 | "Mozilla/5.0 (X11; Linux i686) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.47 Safari/536.11": true, 295 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36": true, 296 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.155 Safari/537.36/Gringe": true, 297 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36": true, 298 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36": true, 299 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/61.0.3163.100 Chrome/61.0.3163.100 Safari/537.36 PingdomPageSpeed/1.0 (pingbot/2.0; +http://www.pingdom.com/)": true, 300 | "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8) Gecko/20060118 Firefox/1.5": true, 301 | "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.8) Gecko/20061201 Firefox/2.0.0.8": true, 302 | "Mozilla/5.0 (X11; U; Linux i686; fr-FR; rv:1.8.1.6) Gecko/20080208 Ubuntu/7.10 (gutsy) Firefox/2.0.0.12": true, 303 | "Mozilla/5.0 (X11; U; Linux i686; fr; rv:1.9.0.9) Gecko/2009042113 Ubuntu/8.04 (hardy) Firefox/3.0.9": true, 304 | "Mozilla/5.0 (X11; U; Linux i686; hu-HU; rv:1.9.1.9) Gecko/20100330 Fedora/3.5.9-1.fc12 Firefox/3.5.9": true, 305 | "Mozilla/5.0 (X11; U; Linux i686; nl; rv:1.9) Gecko/2008061015 Firefox/3.0": true, 306 | "Mozilla/5.0 (X11; U; Linux i686; pt-BR; rv:1.8.0.6) Gecko/20060728 Firefox/1.5.0.6": true, 307 | "Mozilla/5.0 (X11; U; Linux x86_64; cs-CZ; rv:1.9.1.7) Gecko/20100106 Ubuntu/9.10 (karmic) Firefox/3.5.7": true, 308 | "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.8.1.18) Gecko/20081112 Fedora/2.0.0.18-1.fc8 Firefox/2.0.0.18": true, 309 | "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.1.3) Gecko/20090914 Slackware/13.0_stable Firefox/3.5.3": true, 310 | "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9a1) Gecko/20060112 Firefox/1.6a1": true, 311 | "Mozilla/5.0 (X11; U; Linux x86_64; zh-TW; rv:1.9.0.13) Gecko/2009080315 Ubuntu/9.04 (jaunty) Firefox/3.0.13": true, 312 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Xing Bot": true, 313 | "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot": true, 314 | "Mozilla/5.0(compatible;Googlebot/2.1; +http://www.google.com/bot.html)": true, 315 | "Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)": true, 316 | "Mozilla/5.0/(compatible; heritrix/3.3.0-SNAPSHOT-20150803-2130 +http://literatur-im-netz.dla-marbach.de)": true, 317 | "Mozilla/5.0/Chrome/12.0.742.112 (Macintosh; Intel Mac OS X 10_6_8)": true, 318 | "Mozilla/5.0/Firefox/42.0 (contactbigdatafr at gmail.com)": true, 319 | "Mozilla/9.0 (compatible; Staddlebot/1.0)": true, 320 | "myspider/Nutch-1.10": true, 321 | "navigation": true, 322 | "netEstate NE Crawler (+http://www.website-datenbank.de/)": true, 323 | "Netvibes (crawler; http://www.netvibes.com)": true, 324 | "Netvibes (crawler; https://www.netvibes.com)": true, 325 | "Nextcloud Server Crawler": true, 326 | "omgili/0.5 +http://omgili.com": true, 327 | "OWLer/0.1 (built with StormCrawler; https://ows.eu/owler; owl@ow-s.eu)": true, 328 | "PercolateCrawler/4 (ops@percolate.com)": true, 329 | "PhxBot/0.1 (phxbot@protonmail.com)": true, 330 | "PlayStore-Google Mozilla/5.0 Chrome/114.0.5735.179 (KHTML, like Gecko; compatible; +http://www.google.com/bot.html)": true, 331 | "PlurkBot; Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0(facebookexternalhitTwitterbot compatible; PlurkBot; +https://www.plurk.com/)": true, 332 | "PubMatic Crawler Bot": true, 333 | "PulsePoint-Ads.txt-Crawler/1.1": true, 334 | "python-requests/2.6.2 CPython/2.7.6 Linux/3.13.0-61-generic": true, 335 | "Quora-Bot/1.0 (http://www.quora.com)": true, 336 | "R6_CommentReader(www.radian6.com/crawler)": true, 337 | "R6_FeedFetcher(www.radian6.com/crawler)": true, 338 | "RandomWebSiteBot 1.0": true, 339 | "read.write.as": true, 340 | "Readybot.io (https://readybot.io)": true, 341 | "repology-linkchecker/1 (+https://repology.org/docs/bots)": true, 342 | "rogerbot/1.0 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-crawler+shiny@moz.com)": true, 343 | "Ruby": true, 344 | "SafeDNS search bot/Nutch-1.9 (https://www.safedns.com/searchbot; support [at] safedns [dot] com)": true, 345 | "SafeDNSBot (https://www.safedns.com/searchbot)": true, 346 | "Sellers.Guide Crawler by Primis": true, 347 | "semanticbot": true, 348 | "SEMrushBot": true, 349 | "SeRanking SEOChecker": true, 350 | "SerendeputyBot/0.8.6 (http://serendeputy.com/about/serendeputy-bot)": true, 351 | "serpstatbot/2.1 (advanced backlink tracking bot; https://serpstatbot.com/; abuse@serpstatbot.com)": true, 352 | "ShowyouBot (http://showyou.com/crawler)": true, 353 | "SiteCheckerBotCrawler/1.0 (+http://sitechecker.pro)": true, 354 | "Slack-ImgProxy (+https://api.slack.com/robots)": true, 355 | "Slackbot 1.0 (+https://api.slack.com/robots)": true, 356 | "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)": true, 357 | "Slzji.com Search Bot (https://www.slzji.com/search_bot)": true, 358 | "Snap URL Preview Service; bot; snapchat; https://developers.snap.com/robots": true, 359 | "Snap-URL-Preview (bot; snapchat; +https://developers.snap.com/robots)": true, 360 | "Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)": true, 361 | "spider": true, 362 | "spiderbot": true, 363 | "Stratagems Kumo": true, 364 | "Superfeedr bot/2.0 http://superfeedr.com - Make your feeds realtime: get in touch - feed-id:1396250262": true, 365 | "Synapse (bot; +https://github.com/matrix-org/synapse)": true, 366 | "TelegramBot (like TwitterBot)": true, 367 | "TheFeedReaderBot/2.0": true, 368 | "tiny.write.as": true, 369 | "Traackr.com Bot": true, 370 | "Twingly Recon": true, 371 | "Twitterbot": true, 372 | "voltron": true, 373 | "webprosbot/2.0 (+mailto:abuse-6337@webpros.com)": true, 374 | "webscraper": true, 375 | "Who.is Bot": true, 376 | "Wotbox/2.01 (+http://www.wotbox.com/bot/)": true, 377 | "wp.com feedbot/1.0 (+https://wp.com)": true, 378 | "Write.as v1.7.0; Android": true, 379 | "write.as": true, 380 | "WriteFreely.org Crawler (https://writefreely.org/instances)": true, 381 | "Y!J-ASR/0.1 crawler (http://www.yahoo-help.jp/app/answers/detail/p/595/a_id/42716/)": true, 382 | "YisouSpider": true, 383 | "ZoominfoBot (zoominfobot at zoominfo dot com)": true, 384 | } 385 | 386 | var botPrefixes = []string{ 387 | "AdsTxtCrawler/", 388 | "Akkoma ", 389 | "Apache-HttpClient/", 390 | "bitlybot/", 391 | "curl/", 392 | "DuckDuckBot/", 393 | "facebookexternalhit/", 394 | "FediDB/", 395 | "Friendica", 396 | "Googlebot-Image/", 397 | "Googlebot/", 398 | "hackney/", 399 | "http.rb/", 400 | "kbinBot ", 401 | "libwww-perl/", 402 | "Mediumbot-MetaTagFetcher/", 403 | "Mozilla/5.0 (compatible; AhrefsBot/", 404 | "Mozilla/5.0 (compatible; Applebot/", 405 | "Mozilla/5.0 (compatible; archive.org_bot", 406 | "PHP/", 407 | "PixelFedBot/", 408 | "Pleroma ", 409 | "python-requests/", 410 | "Python-urllib/", 411 | "RepoLookoutBot/", 412 | "Screaming Frog SEO Spider/", 413 | "semanticbot ", 414 | "SummalyBot/", 415 | "TelegramBot", 416 | "Twingly Recon-", 417 | "Twitterbot/", 418 | "WhatsApp/", 419 | "yacybot ", 420 | } 421 | 422 | var botPhrases = []string{ 423 | "bingbot", 424 | "Googlebot", 425 | "archive.org_bot", 426 | } 427 | 428 | // IsBot returns whether or not the provided User-Agent string is a known bot 429 | // or crawler. 430 | func IsBot(ua string) bool { 431 | if ua == "" { 432 | return true 433 | } 434 | if _, ok := bots[ua]; ok { 435 | return true 436 | } 437 | for _, p := range botPrefixes { 438 | if strings.HasPrefix(ua, p) { 439 | return true 440 | } 441 | } 442 | for _, p := range botPhrases { 443 | if strings.Contains(ua, p) { 444 | return true 445 | } 446 | } 447 | return false 448 | } 449 | -------------------------------------------------------------------------------- /bots/bots_test.go: -------------------------------------------------------------------------------- 1 | package bots 2 | 3 | import "testing" 4 | 5 | func TestIsBot(t *testing.T) { 6 | tests := map[string]bool{ 7 | "Twitterbot/1.0": true, 8 | "http.rb/2.2.2 (Mastodon/1.6.0; +https://insolente.im/)": true, 9 | "http.rb/2.2.2 (Mastodon/1.5.1; +https://mastodon.cloud/)": true, 10 | "http.rb/2.2.2 (Mastodon/1.6.0rc5; +https://mastodon.sdf.org/)": true, 11 | "http.rb/3.2.0 (Mastodon/2.4.3; +https://qoto.org/)": true, 12 | "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; Touch; rv:11.0) like Gecko": false, 13 | "Mozilla/5.0 (compatible; Applebot/0.3; +http://www.apple.com/go/applebot)": true, 14 | "Mozilla/5.0 (compatible; archive.org_bot +http://www.archive.org/details/archive.org_bot)": true, 15 | "Mozilla/5.0 (compatible; AhrefsBot/5.2; +http://ahrefs.com/robot/)": true, 16 | 17 | "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true, 18 | } 19 | 20 | for ua, r := range tests { 21 | if IsBot(ua) != r { 22 | t.Errorf("Expected bot = %t on '%s'", r, ua) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bots/findBots.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # Generates a Go map containing all bots that have accessed Write.as from the 5 | # application logs stored in /var/log/ 6 | # 7 | # usage: findBots.sh application.log 8 | # 9 | 10 | if [ -z $1 ]; then 11 | echo usage: findBots.sh [logfilename] 12 | exit 1 13 | fi 14 | 15 | cat /var/log/$1 | grep -i 'bot\|spider\|crawl\|scraper\|indexer\|voltron' | awk -F\" '{print $4}' | sort | uniq > bots.txt 16 | 17 | rm bots.go 18 | 19 | cat > bots.go << EOM 20 | // This package helps the backend determine which clients are bots or crawlers. 21 | // In Write.as, this is used to prevent certain things when viewing posts, like 22 | // incrementing the view count. 23 | package bots 24 | 25 | var bots = map[string]bool { 26 | EOM 27 | 28 | while read b; do 29 | if [ -n "$b" ]; then 30 | echo " \"$b\": true," >> bots.go 31 | fi 32 | done > bots.go << EOM 35 | }; 36 | 37 | // IsBot returns whether or not the provided User-Agent string is a known bot 38 | // or crawler. 39 | func IsBot(ua string) bool { 40 | if _, ok := bots[ua]; ok { 41 | return true 42 | } 43 | return false 44 | } 45 | EOM 46 | -------------------------------------------------------------------------------- /category/category.go: -------------------------------------------------------------------------------- 1 | // Package category supports post categories 2 | package category 3 | 4 | import ( 5 | "errors" 6 | "github.com/writeas/slug" 7 | ) 8 | 9 | var ( 10 | ErrNotFound = errors.New("category doesn't exist") 11 | ) 12 | 13 | // Category represents a post tag with additional metadata, like a title and slug. 14 | type Category struct { 15 | ID int64 `json:"-"` 16 | Hashtag string `json:"hashtag"` 17 | Slug string `json:"slug"` 18 | Title string `json:"title"` 19 | PostCount int64 `json:"post_count"` 20 | 21 | // IsCategory distinguishes this Category from a mere tag. If true, it is often prominently featured on a blog, 22 | // and looked up via its Slug instead of its Hashtag. It usually has all metadata, such as Title, correctly 23 | // populated. 24 | IsCategory bool `json:"-"` 25 | } 26 | 27 | // NewCategory creates a Category you can insert into the database, based on a hashtag. It automatically breaks up the 28 | // hashtag by words, based on capitalization, for both the title and a URL-friendly slug. 29 | func NewCategory(hashtag string) *Category { 30 | title := titleFromHashtag(hashtag) 31 | return &Category{ 32 | Hashtag: hashtag, 33 | Slug: slug.Make(title), 34 | Title: title, 35 | } 36 | } 37 | 38 | // NewCategoryFromPartial creates a Category from a partially-populated Category, such as when a user initially creates 39 | // one. 40 | func NewCategoryFromPartial(cat *Category) *Category { 41 | newCat := &Category{ 42 | Hashtag: cat.Hashtag, 43 | } 44 | // Create title from hashtag, if none supplied 45 | if cat.Title == "" { 46 | newCat.Title = titleFromHashtag(cat.Hashtag) 47 | } else { 48 | newCat.Title = cat.Title 49 | } 50 | // Create slug from title, if none supplied; otherwise ensure slug is valid 51 | if cat.Slug == "" { 52 | newCat.Slug = slug.Make(newCat.Title) 53 | } else { 54 | newCat.Slug = slug.Make(cat.Slug) 55 | } 56 | return newCat 57 | } 58 | -------------------------------------------------------------------------------- /category/tags.go: -------------------------------------------------------------------------------- 1 | package category 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | ) 7 | 8 | // titleFromHashtag generates an all-lowercase title, with spaces inserted based on initial capitalization -- e.g. 9 | // "MyWordyTag" becomes "my wordy tag". 10 | func titleFromHashtag(hashtag string) string { 11 | var t strings.Builder 12 | var prev rune 13 | for i, c := range hashtag { 14 | if unicode.IsUpper(c) { 15 | if i > 0 && !unicode.IsUpper(prev) { 16 | // Insert space if previous rune wasn't also uppercase (e.g. an abbreviation) 17 | t.WriteRune(' ') 18 | } 19 | t.WriteRune(unicode.ToLower(c)) 20 | } else { 21 | t.WriteRune(c) 22 | } 23 | prev = c 24 | } 25 | return t.String() 26 | } 27 | 28 | // HashtagFromTitle generates a valid single-word, camelCase hashtag from a title (which might include spaces, 29 | // punctuation, etc.). 30 | func HashtagFromTitle(title string) string { 31 | var t strings.Builder 32 | var prev rune 33 | for _, c := range title { 34 | if !unicode.IsLetter(c) && !unicode.IsNumber(c) { 35 | prev = c 36 | continue 37 | } 38 | if unicode.IsSpace(prev) { 39 | // Uppercase next word 40 | t.WriteRune(unicode.ToUpper(c)) 41 | } else { 42 | t.WriteRune(c) 43 | } 44 | prev = c 45 | } 46 | return t.String() 47 | } 48 | -------------------------------------------------------------------------------- /category/tags_test.go: -------------------------------------------------------------------------------- 1 | package category 2 | 3 | import "testing" 4 | 5 | func TestTitleFromHashtag(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | hashtag string 9 | expTitle string 10 | }{ 11 | {"proper noun", "Jane", "jane"}, 12 | {"full name", "JaneDoe", "jane doe"}, 13 | {"us words", "unitedStates", "united states"}, 14 | {"usa", "USA", "usa"}, 15 | {"us monoword", "unitedstates", "unitedstates"}, 16 | {"100dto", "100DaysToOffload", "100 days to offload"}, 17 | {"iphone", "iPhone", "iphone"}, 18 | {"ilike", "iLikeThis", "i like this"}, 19 | {"abird", "aBird", "a bird"}, 20 | {"all caps", "URGENT", "urgent"}, 21 | {"smartphone", "スマートフォン", "スマートフォン"}, 22 | } 23 | for _, test := range tests { 24 | t.Run(test.name, func(t *testing.T) { 25 | res := titleFromHashtag(test.hashtag) 26 | if res != test.expTitle { 27 | t.Fatalf("#%s: got '%s' expected '%s'", test.hashtag, res, test.expTitle) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func TestHashtagFromTitle(t *testing.T) { 34 | tests := []struct { 35 | name string 36 | title string 37 | expHashtag string 38 | }{ 39 | {"proper noun", "Jane", "Jane"}, 40 | {"full name", "Jane Doe", "JaneDoe"}, 41 | {"us upper words", "United States", "UnitedStates"}, 42 | {"us lower words", "united states", "unitedStates"}, 43 | {"usa", "USA", "USA"}, 44 | {"100dto", "100 Days To Offload", "100DaysToOffload"}, 45 | {"iphone", "iPhone", "iPhone"}, 46 | {"ilike", "I like this", "ILikeThis"}, 47 | {"abird", "a Bird", "aBird"}, 48 | {"all caps", "URGENT", "URGENT"}, 49 | {"punctuation", "John’s Stories", "JohnsStories"}, 50 | {"smartphone", "スマートフォン", "スマートフォン"}, 51 | } 52 | for _, test := range tests { 53 | t.Run(test.name, func(t *testing.T) { 54 | res := HashtagFromTitle(test.title) 55 | if res != test.expHashtag { 56 | t.Fatalf("%s: got '%s' expected '%s'", test.title, res, test.expHashtag) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /converter/json.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "reflect" 7 | ) 8 | 9 | type NullJSONBool struct { 10 | sql.NullBool 11 | } 12 | 13 | func JSONNullBool(value string) reflect.Value { 14 | v := NullJSONBool{} 15 | 16 | if value == "on" || value == "off" { 17 | return reflect.ValueOf(NullJSONBool{sql.NullBool{Bool: value == "on", Valid: true}}) 18 | } 19 | if err := v.Scan(value); err != nil { 20 | return reflect.Value{} 21 | } 22 | return reflect.ValueOf(v) 23 | } 24 | 25 | func (v NullJSONBool) MarshalJSON() ([]byte, error) { 26 | if v.Valid { 27 | return json.Marshal(v.Bool) 28 | } else { 29 | return json.Marshal(nil) 30 | } 31 | } 32 | 33 | func (v *NullJSONBool) UnmarshalJSON(data []byte) error { 34 | // Unmarshalling into a pointer will let us detect null 35 | var x *bool 36 | if err := json.Unmarshal(data, &x); err != nil { 37 | return err 38 | } 39 | if x != nil { 40 | v.Valid = true 41 | v.Bool = *x 42 | } else { 43 | v.Valid = false 44 | } 45 | return nil 46 | } 47 | 48 | type NullJSONString struct { 49 | sql.NullString 50 | } 51 | 52 | func JSONNullString(value string) reflect.Value { 53 | v := NullJSONString{} 54 | if err := v.Scan(value); err != nil { 55 | return reflect.Value{} 56 | } 57 | 58 | return reflect.ValueOf(v) 59 | } 60 | 61 | func (v NullJSONString) MarshalJSON() ([]byte, error) { 62 | if v.Valid { 63 | return json.Marshal(v.String) 64 | } else { 65 | return json.Marshal(nil) 66 | } 67 | } 68 | 69 | func (v *NullJSONString) UnmarshalJSON(data []byte) error { 70 | // Unmarshalling into a pointer will let us detect null 71 | var x *string 72 | if err := json.Unmarshal(data, &x); err != nil { 73 | return err 74 | } 75 | if x != nil { 76 | v.Valid = true 77 | v.String = *x 78 | } else { 79 | v.Valid = false 80 | } 81 | return nil 82 | } 83 | 84 | func ConvertJSONNullString(value string) reflect.Value { 85 | v := NullJSONString{} 86 | if err := v.Scan(value); err != nil { 87 | return reflect.Value{} 88 | } 89 | 90 | return reflect.ValueOf(v) 91 | } 92 | 93 | func ConvertJSONNullBool(value string) reflect.Value { 94 | v := NullJSONBool{} 95 | 96 | if value == "on" || value == "off" { 97 | return reflect.ValueOf(NullJSONBool{sql.NullBool{Bool: value == "on", Valid: true}}) 98 | } 99 | if err := v.Scan(value); err != nil { 100 | return reflect.Value{} 101 | } 102 | return reflect.ValueOf(v) 103 | } 104 | -------------------------------------------------------------------------------- /converter/sql.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "database/sql" 5 | "reflect" 6 | ) 7 | 8 | func SQLNullString(value string) reflect.Value { 9 | v := sql.NullString{} 10 | if err := v.Scan(value); err != nil { 11 | return reflect.Value{} 12 | } 13 | 14 | return reflect.ValueOf(v) 15 | } 16 | 17 | func SQLNullBool(value string) reflect.Value { 18 | v := sql.NullBool{} 19 | if err := v.Scan(value); err != nil { 20 | return reflect.Value{} 21 | } 22 | 23 | return reflect.ValueOf(v) 24 | } 25 | 26 | func SQLNullInt64(value string) reflect.Value { 27 | v := sql.NullInt64{} 28 | if err := v.Scan(value); err != nil { 29 | return reflect.Value{} 30 | } 31 | 32 | return reflect.ValueOf(v) 33 | } 34 | 35 | func SQLNullFloat64(value string) reflect.Value { 36 | v := sql.NullFloat64{} 37 | if err := v.Scan(value); err != nil { 38 | return reflect.Value{} 39 | } 40 | 41 | return reflect.ValueOf(v) 42 | } 43 | 44 | func ConvertSQLNullString(value string) reflect.Value { 45 | v := sql.NullString{} 46 | if err := v.Scan(value); err != nil { 47 | return reflect.Value{} 48 | } 49 | 50 | return reflect.ValueOf(v) 51 | } 52 | 53 | func ConvertSQLNullBool(value string) reflect.Value { 54 | v := sql.NullBool{} 55 | if err := v.Scan(value); err != nil { 56 | return reflect.Value{} 57 | } 58 | 59 | return reflect.ValueOf(v) 60 | } 61 | 62 | func ConvertSQLNullInt64(value string) reflect.Value { 63 | v := sql.NullInt64{} 64 | if err := v.Scan(value); err != nil { 65 | return reflect.Value{} 66 | } 67 | 68 | return reflect.ValueOf(v) 69 | } 70 | 71 | func ConvertSQLNullFloat64(value string) reflect.Value { 72 | v := sql.NullFloat64{} 73 | if err := v.Scan(value); err != nil { 74 | return reflect.Value{} 75 | } 76 | 77 | return reflect.ValueOf(v) 78 | } 79 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | // Package data provides utilities for interacting with database data 2 | // throughout Write.as. 3 | package data 4 | 5 | import ( 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "crypto/rand" 9 | "errors" 10 | "fmt" 11 | ) 12 | 13 | // Encryption parameters 14 | const ( 15 | keyLen = 32 16 | delimiter = '%' 17 | ) 18 | 19 | // Encrypt AES-encrypts given text with the given key k. 20 | // This is used for encrypting sensitive information in the database, such as 21 | // oAuth tokens and email addresses. 22 | func Encrypt(k []byte, text string) ([]byte, error) { 23 | // Validate parameters 24 | if len(k) != keyLen { 25 | return nil, errors.New(fmt.Sprintf("Invalid key length (must be %d bytes).", keyLen)) 26 | } 27 | 28 | // Encrypt plaintext with AES-GCM 29 | block, err := aes.NewCipher(k) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | gcm, err := cipher.NewGCM(block) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | // Generate nonce 40 | ns := gcm.NonceSize() 41 | nonce := make([]byte, ns) 42 | if _, err := rand.Read(nonce); err != nil { 43 | return nil, err 44 | } 45 | 46 | ciphertext := gcm.Seal(nil, nonce, []byte(text), nil) 47 | 48 | // Build text output in the format: 49 | // NonceCiphertext 50 | outtext := append(nonce, ciphertext...) 51 | 52 | return outtext, nil 53 | } 54 | 55 | // Decrypt decrypts the given ciphertext with the given key k. 56 | func Decrypt(k, ciphertext []byte) ([]byte, error) { 57 | // Decrypt ciphertext 58 | block, err := aes.NewCipher(k) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | gcm, err := cipher.NewGCM(block) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | ns := gcm.NonceSize() 69 | 70 | // Validate data 71 | if len(ciphertext) < ns { 72 | return nil, errors.New("Ciphertext is too short") 73 | } 74 | 75 | nonce := ciphertext[:ns] 76 | ciphertext = ciphertext[ns:] 77 | plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return plaintext, nil 83 | } 84 | -------------------------------------------------------------------------------- /data/data_test.go: -------------------------------------------------------------------------------- 1 | // Package data provides utilities for interacting with database data 2 | // throughout Write.as. 3 | package data 4 | 5 | import ( 6 | "bytes" 7 | "crypto/rand" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestEncDec(t *testing.T) { 13 | // Generate a random key with a valid length 14 | k := make([]byte, keyLen) 15 | _, err := rand.Read(k) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | runEncDec(t, k, "this is my secret message™. 😄", nil) 21 | runEncDec(t, k, "mygreatemailaddress@gmail.com", nil) 22 | } 23 | 24 | func TestAuthentication(t *testing.T) { 25 | // Generate a random key with a valid length 26 | k := make([]byte, keyLen) 27 | _, err := rand.Read(k) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | runEncDec(t, k, "mygreatemailaddress@gmail.com", func(c []byte) []byte { 33 | c[0] = 'a' 34 | t.Logf("Modified: %s\n", c) 35 | return c 36 | }) 37 | } 38 | 39 | func runEncDec(t *testing.T, k []byte, plaintext string, transform func([]byte) []byte) { 40 | t.Logf("Plaintext: %s\n", plaintext) 41 | 42 | // Encrypt the data 43 | ciphertext, err := Encrypt(k, plaintext) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | t.Logf("Ciphertext: %s\n", ciphertext) 49 | 50 | if transform != nil { 51 | ciphertext = transform(ciphertext) 52 | } 53 | 54 | // Decrypt the data 55 | decryptedText, err := Decrypt(k, ciphertext) 56 | if err != nil { 57 | if transform != nil && strings.Contains(err.Error(), "message authentication failed") { 58 | // We modified the ciphertext; make sure we're getting the right error 59 | t.Logf("%v\n", err) 60 | return 61 | } 62 | t.Fatal(err) 63 | } 64 | 65 | t.Logf("Decrypted: %s\n", string(decryptedText)) 66 | 67 | if !bytes.Equal([]byte(plaintext), decryptedText) { 68 | t.Errorf("Plaintext mismatch: got %x vs %x", plaintext, decryptedText) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /errors/general.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "github.com/writeas/impart" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | // Commonly returned HTTP errors 10 | ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."} 11 | ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."} 12 | ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."} 13 | ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."} 14 | ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."} 15 | ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."} 16 | ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."} 17 | 18 | // Post operation errors 19 | ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."} 20 | ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."} 21 | 22 | // Internal errors 23 | ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} 24 | ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} 25 | 26 | // User errors 27 | ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} 28 | ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} 29 | ErrUsernameTaken = impart.HTTPError{http.StatusConflict, "Username is already taken."} 30 | ) 31 | -------------------------------------------------------------------------------- /errors/snapas.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "github.com/writeas/impart" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | ErrInvalidFeature = impart.HTTPError{http.StatusBadRequest, "Not a valid feature."} 10 | ) 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/writeas/web-core 2 | 3 | go 1.10 4 | 5 | require ( 6 | github.com/gofrs/uuid v3.3.0+incompatible 7 | github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec 8 | github.com/microcosm-cc/bluemonday v1.0.23 9 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect 10 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 11 | github.com/writeas/go-strip-markdown/v2 v2.1.1 12 | github.com/writeas/impart v1.1.1 13 | github.com/writeas/openssl-go v1.0.0 14 | github.com/writeas/saturday v1.7.1 15 | github.com/writeas/slug v1.2.0 16 | golang.org/x/crypto v0.35.0 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 18 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 3 | github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= 4 | github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 5 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 7 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 8 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 9 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 10 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 11 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 12 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 13 | github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI= 14 | github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= 15 | github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= 16 | github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= 17 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= 18 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 19 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 20 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 21 | github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= 22 | github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= 23 | github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o= 24 | github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= 25 | github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= 26 | github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= 27 | github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE= 28 | github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= 29 | github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= 30 | github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= 31 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 34 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 35 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 36 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 37 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 38 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 39 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 40 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 41 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 42 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 43 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 44 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 46 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 47 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 48 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 49 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 50 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 51 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 52 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 53 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 54 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 55 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 56 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 58 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 59 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 60 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 71 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 73 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 74 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 75 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 76 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 77 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 78 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 79 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 80 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 81 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 82 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 83 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 84 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 85 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 86 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 87 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 88 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 89 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 90 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 91 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 92 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 93 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 94 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 95 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 96 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 97 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 98 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 99 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 100 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 101 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 102 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= 103 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 104 | -------------------------------------------------------------------------------- /i18n/rtl.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | var rtlLangs = map[string]bool{ 4 | "ar": true, // Arabic 5 | "dv": true, // Divehi 6 | "fa": true, // Persian (Farsi) 7 | "ha": true, // Hausa 8 | "he": true, // Hebrew 9 | "iw": true, // Hebrew (old code) 10 | "ji": true, // Yiddish (old code) 11 | "ps": true, // Pashto, Pushto 12 | "ur": true, // Urdu 13 | "yi": true, // Yiddish 14 | } 15 | 16 | func LangIsRTL(lang string) bool { 17 | if _, ok := rtlLangs[lang]; ok { 18 | return true 19 | } 20 | return false 21 | } 22 | -------------------------------------------------------------------------------- /id/random.go: -------------------------------------------------------------------------------- 1 | package id 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | // GenerateRandomString creates a random string of characters of the given 9 | // length from the given dictionary of possible characters. 10 | // 11 | // This example generates a hexadecimal string 6 characters long: 12 | // GenerateRandomString("0123456789abcdef", 6) 13 | func GenerateRandomString(dictionary string, l int) string { 14 | var bytes = make([]byte, l) 15 | rand.Read(bytes) 16 | for k, v := range bytes { 17 | bytes[k] = dictionary[v%byte(len(dictionary))] 18 | } 19 | return string(bytes) 20 | } 21 | 22 | // GenSafeUniqueSlug generatees a reasonably unique random slug from the given 23 | // original slug. It's "safe" because it uses 0-9 b-z excluding vowels. 24 | func GenSafeUniqueSlug(slug string) string { 25 | return fmt.Sprintf("%s-%s", slug, GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 4)) 26 | } 27 | 28 | // Generate62RandomString creates a random string with the given length 29 | // consisting of characters in [A-Za-z0-9]. 30 | func Generate62RandomString(l int) string { 31 | return GenerateRandomString("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", l) 32 | } 33 | 34 | // GenerateFriendlyRandomString creates a random string of characters with the 35 | // given length consisting of characters in [a-z0-9]. 36 | func GenerateFriendlyRandomString(l int) string { 37 | return GenerateRandomString("0123456789abcdefghijklmnopqrstuvwxyz", l) 38 | } 39 | -------------------------------------------------------------------------------- /id/random_test.go: -------------------------------------------------------------------------------- 1 | package id 2 | 3 | import "testing" 4 | 5 | func TestGenSafeUniqueSlug(t *testing.T) { 6 | slug := "slug" 7 | r := map[string]bool{} 8 | 9 | for i := 0; i < 1000; i++ { 10 | s := GenSafeUniqueSlug(slug) 11 | if s == slug { 12 | t.Errorf("Got same slug as inputted!") 13 | } 14 | if _, ok := r[s]; ok { 15 | t.Logf("#%d: slug %s was already generated in testing.", i, s) 16 | } 17 | r[s] = true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /l10n/phrases.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | // Term constants 4 | const ( 5 | DefReadMore = "Read more..." 6 | DefPublishedWith = "published with write.as" 7 | DefOlder = "Older" 8 | DefNewer = "Newer" 9 | ) 10 | 11 | // Default phrases 12 | var phrases = map[string]string{ 13 | "Anonymous post": "Anonymous post", 14 | "Blogs": "Blogs", 15 | "Enter": "Enter", 16 | "Newer": "Newer", 17 | "Next": "Next", 18 | "Older": "Older", 19 | "Posts": "Posts", 20 | "Previous": "Previous", 21 | "Publish to...": "Publish to...", 22 | "Publish": "Publish", 23 | "Read more...": "Read more...", 24 | "Subscribe": "Subscribe", 25 | "This blog requires a password.": "This blog requires a password.", 26 | "Toggle theme": "Toggle theme", 27 | "View posts": "View Posts", 28 | "delete": "delete", 29 | "edit": "edit", 30 | "email subscription confirm": "Please check your email and click the confirmation link to subscribe.", 31 | "email subscription prompt": "Enter your email to subscribe to updates.", 32 | "email subscription success": "Subscribed. You'll now receive future blog posts via email.", 33 | "move to...": "move to...", 34 | "pin": "pin", 35 | "published with write.as": "published with write.as", 36 | "share modal ending": "Send it to a friend, share it across the web, or maybe tweet it. Learn more.", 37 | "share modal introduction": "Each published post has a secret, unique URL you can share with anyone. This is that URL:", 38 | "share modal title": "Share this post", 39 | "share": "share", 40 | "unpin": "unpin", 41 | "title dash": "—", 42 | } 43 | -------------------------------------------------------------------------------- /l10n/phrases_ar.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesAR = map[string]string{ 4 | "Anonymous post": "منشور مجهول المصدر", 5 | "Blogs": "المدونات", 6 | "Enter": "أدخل", 7 | "Newer": "الأحدث", 8 | "Older": "الأقدم", 9 | "Posts": "المنشورات", 10 | "Publish to...": "النشر إلى…", 11 | "Publish": "نشر", 12 | "Read more...": "اقرأ المزيد…", 13 | "Subscribe": "اشترك", 14 | "This blog requires a password.": "هذه المدونة تتطلب کلمة سرية.", 15 | "Toggle theme": "تغيير القالب", 16 | "View posts": "مشاهدة المنشورات", 17 | "delete": "حذف", 18 | "edit": "تعديل", 19 | "email subscription prompt": "ادخل عنوان بريدك الإلكتروني للإشتراك في التحديثات", 20 | "move to...": "أنقله إلى…", 21 | "pin": "تدبيس", 22 | "published with write.as": "مدعوم من write.as", 23 | "share modal ending": "ارسلهه إلى صديق، شاركه عبر الويب، أو غرّده. تعلّم المزيد.", 24 | "share modal introduction": "كل الفيديوهات المنشورة لديها رابط سري مميز، يمكنك مشاركته. هذا هو الرابط:", 25 | "share modal title": "شارك هذا المنشور", 26 | "share": "شارك", 27 | "unpin": "إلغاء التدبيس", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_cs.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesCS = map[string]string{ 4 | "Anonymous post": "Anonymní článek", 5 | "Blogs": "Blogy", 6 | "Enter": "Vstoupit", 7 | "Newer": "Novější", 8 | "Older": "Starší", 9 | "Posts": "Články", 10 | "Publish to...": "Zveřejnit do...", 11 | "Publish": "Zveřejnit", 12 | "Read more...": "Číst dále...", 13 | "Subscribe": "Odebírat", 14 | "This blog requires a password.": "Tento blog je zaheslován.", 15 | "Toggle theme": "Přepnout vzhled", 16 | "View posts": "Zobrazit články", 17 | "delete": "smazat", 18 | "edit": "upravit", 19 | "email subscription prompt": "Zadejte svůj e-mail a přihlaste se k odběru aktualizací.", 20 | "move to...": "přesunout do...", 21 | "pin": "připnout", 22 | "published with write.as": "publikováno pomocí write.as", 23 | "share modal ending": "Pošlete ji kamarádům, sdílejte na webu nebo na sociálních sítích. Zjistit více.", 24 | "share modal introduction": "Každý zveřejněný článek má tajnou unikátní adresu, kterou můžete komukoliv poslat. Unikátní adresa pro tento článek je:", 25 | "share modal title": "Sdílet tento článek", 26 | "share": "sdílet", 27 | "unpin": "odepnout", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_da.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesDA = map[string]string{ 4 | "Anonymous post": "Anonymt indlæg", 5 | "Blogs": "Blogs", 6 | "Enter": "Enter", 7 | "Newer": "Nyere", 8 | "Older": "Ældre", 9 | "Posts": "Indlæg", 10 | "Publish to...": "Udgiv på...", 11 | "Publish": "Udgiv", 12 | "Read more...": "Læs videre...", 13 | "Subscribe": "Abonnér", 14 | "This blog requires a password.": "Den her blog kræver en adgangskode.", 15 | "Toggle theme": "Skift design", 16 | "View posts": "Vis indlæg", 17 | "delete": "slet", 18 | "edit": "redigér", 19 | "email subscription prompt": "Indtast din email for at få tilsendt opdateringer.", 20 | "move to...": "flyt til...", 21 | "pin": "sæt øverst", 22 | "published with write.as": "udgivet med write.as", 23 | "share modal ending": "Send det til en ven, del det på nettet, eller lav et tweet. Læs mere.", 24 | "share modal introduction": "Hvert indlæg har sit eget, hemmelige link, som du kan dele med enhver. Her er det førnævnte link:", 25 | "share modal title": "Del indlæg", 26 | "share": "del", 27 | "unpin": "fjern øverst", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_de.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesDE = map[string]string{ 4 | "Anonymous post": "Anonymer Beitrag", 5 | "Blogs": "Blogs", 6 | "Enter": "Eingabe", 7 | "Newer": "Neuer", 8 | "Older": "Älter", 9 | "Posts": "Beiträge", 10 | "Publish to...": "Veröffentlichen zu", 11 | "Publish": "Veröffentlichen", 12 | "Read more...": "Weiterlesen...", 13 | "Subscribe": "Abonnieren", 14 | "This blog requires a password.": "Dieser Blog benötigt ein Passwort", 15 | "Toggle theme": "Design ändern", 16 | "View posts": "Beiträge ansehen", 17 | "delete": "Löschen", 18 | "edit": "Bearbeiten", 19 | "email subscription prompt": "Geben Sie Ihre E-Mail-Adresse ein, um Updates zu abonnieren.", 20 | "move to...": "Verschieben nach...", 21 | "pin": "Anheften", 22 | "published with write.as": "Veröffentlicht mit write.as", 23 | "share modal ending": "Schicke es Freunden, teile es im Netz oder vielleicht tweete es. Lerne mehr.", 24 | "share modal introduction": "Jeder veröffentlichte Beitrag hat eine geheime, einmalige URL, die du mit jedem teilen kannst. Die URL ist:", 25 | "share modal title": "Teile diesen Beitrag", 26 | "share": "Teilen", 27 | "unpin": "Lösen", 28 | "title dash": "–", 29 | } 30 | -------------------------------------------------------------------------------- /l10n/phrases_el.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesEL = map[string]string{ 4 | "Anonymous post": "Ανώνυμη δημοσίευση", 5 | "Blogs": "Ιστολόγια", 6 | "Enter": "Είσοδος", 7 | "Newer": "Νεότερα", 8 | "Older": "Παλαιότερα", 9 | "Posts": "Δημοσιεύσεις", 10 | "Publish to...": "Δημοσίευση στο...", 11 | "Publish": "Δημοσίευση", 12 | "Read more...": "Διαβάστε περισσότερα...", 13 | "Subscribe": "Εγγραφείτε", 14 | "This blog requires a password.": "Αυτό το ιστολόγιο απαιτεί κωδικό.", 15 | "Toggle theme": "Αλλαγή θέματος", 16 | "View posts": "Προβολή Δημοσιεύσεων", 17 | "delete": "διαγραφή", 18 | "edit": "επεξεργασία", 19 | "email subscription prompt": "Εισάγετε το email σας για να εγγραφείτε στις ενημερώσεις.", 20 | "move to...": "μετακίνηση στο...", 21 | "pin": "καρφίτσωμα", 22 | "published with write.as": "δημοσιεύθηκε με το write.as", 23 | "share modal ending": "Στείλτε το σε έναν φίλο, μοιραστείτε το στο διαδίκτυο ή κάντε το tweet. Μάθετε περισσότερα.", 24 | "share modal introduction": "Κάθε αναρτημένη δημοσίευση έχει ένα κρυφό, μοναδικό σύνδεσμο που μπορείτε να μοιραστείτε με οποιονδήποτε. Αυτός είναι ο εν λόγω σύνδεσμος:", 25 | "share modal title": "Μοιραστείτε αυτή τη δημοσίευση", 26 | "share": "διαμοιρασμός", 27 | "unpin": "ξεκαρφίτσωμα", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_eo.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesEO = map[string]string{ 4 | "Anonymous post": "Anonima afiŝo", 5 | "Blogs": "Blogoj", 6 | "Enter": "Eniri", 7 | "Newer": "Pli nova", 8 | "Older": "Pli malnova", 9 | "Posts": "Afiŝoj", 10 | "Publish to...": "Eldonu je...", 11 | "Publish": "Eldonu", 12 | "Read more...": "Legu pli...", 13 | "This blog requires a password.": "Ĉi tiu blogo postulas pasvorton.", 14 | "Toggle theme": "Ŝanĝi temon", 15 | "View posts": "Rigardi Afiŝojn", 16 | "delete": "forigi", 17 | "edit": "redakti", 18 | "move to...": "movi al...", 19 | "pin": "alpingli", 20 | "published with write.as": "Eldonita per write.as", 21 | "share modal ending": "Sendu ĝin al unu amiko, kunhavu ĝin tra la retejo, aŭ eble tweet ĝin. Lernu pli.", 22 | "share modal introduction": "Ĉiu eldonita afiŝo havas sekretan, unikan URL-on, kiun vi povas kunhavigi kun iu ajn. Ĉi tiu estas tiu URL-o:", 23 | "share modal title": "Kunhavigi ĉi tiun afiŝon", 24 | "share": "kunhavigi", 25 | "unpin": "malalpingli", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_es.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesES = map[string]string{ 4 | "Anonymous post": "Entrada anónima", 5 | "Blogs": "Blogs", 6 | "Newer": "Más nuevo", 7 | "Older": "Más antiguo", 8 | "Posts": "Entradas", 9 | "Publish to...": "Publicar en...", 10 | "Publish": "Publicar", 11 | "Read more...": "Leer más...", 12 | "Toggle theme": "Cambiar tema", 13 | "View posts": "Ver entradas", 14 | "delete": "eliminar", 15 | "edit": "editar", 16 | "move to...": "mover a", 17 | "pin": "anclar", 18 | "published with write.as": "publicado con write.as", 19 | "share modal ending": "Enviar a un amigo, compartir a través de la web, o quizá tuitear la entrada. Aprende más.", 20 | "share modal introduction": "Cada entrada publicada tiene una secreta y única URL que puedes compartir con cualquiera. Esta es la URL:", 21 | "share modal title": "Compartir esta entrada", 22 | "share": "compartir", 23 | "unpin": "desanclar", 24 | } 25 | -------------------------------------------------------------------------------- /l10n/phrases_eu.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesEU = map[string]string{ 4 | "Anonymous post": "Bidalketa anonimoa", 5 | "Blogs": "Blogak", 6 | "Enter": "Sartu", 7 | "Newer": "Berriagoak", 8 | "Older": "Zaharragoak", 9 | "Posts": "Bidalketak", 10 | "Publish to...": "Argitaratu hemen...", 11 | "Publish": "Argitaratu", 12 | "Read more...": "Irakurri gehiago...", 13 | "Subscribe": "Izena eman", 14 | "This blog requires a password.": "Blog honek pasahitza behar du.", 15 | "Toggle theme": "Aldatu itxura", 16 | "View posts": "Ikusi bidalketak", 17 | "delete": "ezabatu", 18 | "edit": "editatu", 19 | "email subscription prompt": "Sartu zure e-posta helbidea berriak jasotzeko.", 20 | "move to...": "mugitu hona...", 21 | "pin": "finkatu", 22 | "published with write.as": "write.as bidez argitaratua", 23 | "share modal ending": "Bidali lagun bati, partekatu sarean zehar edo agian, txiokatu. Ikasi gehiago.", 24 | "share modal introduction": "Argitaratutako bidalketa orok URL sekretu eta bakarra dauka, edonorekin partekatu dezakezuna. Hau da URLa:", 25 | "share modal title": "Partekatu bidalketa hau", 26 | "share": "partekatu", 27 | "unpin": "desfinkatu", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_fa.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesFA = map[string]string{ 4 | "Anonymous post": "پست ناشناس", 5 | "Blogs": "وبلاگ‌ها", 6 | "Enter": "ورود", 7 | "Newer": "بعدتر", 8 | "Older": "قبل‌تر", 9 | "Posts": "مطالب", 10 | "Publish to...": "انتشار در...", 11 | "Publish": "انتشار", 12 | "Read more...": "بیشتر بخوانید...", 13 | "This blog requires a password.": "ورود به این وبلاگ نیازمند گذرواژه است.", 14 | "Toggle theme": "تغییر نمایه", 15 | "View posts": "مشاهده‌ی مطالب", 16 | "delete": "حذف", 17 | "edit": "ویرایش", 18 | "move to...": "انتقال به...", 19 | "pin": "پین کردن", 20 | "published with write.as": "قدرت گرفته از write.as", 21 | "share modal ending": "برای دوستانتان بفرستید، در بستر وب اشتراک بگذارید، یا درباره‌اش توییت کنید. بیشتر بیاموزید.", 22 | "share modal introduction": "هر پست یک آدرس محرمانه‌ی یکتا دارد که می‌توانید با دیگران به اشتراک بگذارید. این آن آدرس است:", 23 | "share modal title": "به‌اشتراک‌گذاری این مطلب", 24 | "share": "به‌اشتراک‌گذاری", 25 | "unpin": "از پین درآوردن", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_fr.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesFR = map[string]string{ 4 | "Anonymous post": "Billet anonyme", 5 | "Blogs": "Blogs", 6 | "Enter": "Valider", 7 | "Newer": "Récents", 8 | "Older": "Précédents", 9 | "Posts": "Billets", 10 | "Publish to...": "Publier sur...", 11 | "Publish": "Publier", 12 | "Read more...": "Lire la suite...", 13 | "Subscribe": "S'abonner", 14 | "This blog requires a password.": "Ce blog requiert un mot de passe.", 15 | "Toggle theme": "Changer de thème", 16 | "View posts": "Voir les billets", 17 | "delete": "effacer", 18 | "edit": "modifier", 19 | "email subscription prompt": "Insérer votre adresse email pour recevoir les mises à jour", 20 | "move to...": "déplacer vers...", 21 | "pin": "épingler", 22 | "published with write.as": "publié avec write.as", 23 | "share modal ending": "Envoyer à un ami, partager sur le web, ou peut-être en tant que tweet. En savoir plus.", 24 | "share modal introduction": "Chaque billet dispose d’une adresse (URL) secrète et unique qui peut être partagée avec quelqu’un. Voici cette URL:", 25 | "share modal title": "Partager ce billet", 26 | "share": "partager", 27 | "unpin": "détacher", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_gl.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesGL = map[string]string{ 4 | "Anonymous post": "Publicación anónima", 5 | "Blogs": "Blogs", 6 | "Enter": "Entrar", 7 | "Newer": "Máis recente", 8 | "Older": "Máis antigo", 9 | "Posts": "Publicacións", 10 | "Publish to...": "Publicar en...", 11 | "Publish": "Publicar", 12 | "Read more...": "Saber máis...", 13 | "Subscribe": "Subscribir", 14 | "This blog requires a password.": "Este blog require un contrasinal.", 15 | "Toggle theme": "Cambiar decorado", 16 | "View posts": "Ver Publicacións", 17 | "delete": "eliminar", 18 | "edit": "editar", 19 | "email subscription prompt": "Escribe o teu email para recibir actualizacións.", 20 | "move to...": "mover a...", 21 | "pin": "fixar", 22 | "published with write.as": "publicado con write.as", 23 | "share modal ending": "Envíallo a un amigo, compárteo en internet e redes sociais. Saber máis.", 24 | "share modal introduction": "Cada publicación ten un identificador, un URL único que podes compartir con calquera. Este é o URL:", 25 | "share modal title": "Comparte a publicación", 26 | "share": "compartir", 27 | "unpin": "desafixar", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_he.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesHE = map[string]string{ 4 | "Anonymous post": "פוסטים אנונימיים", 5 | "Blogs": "בלוגים", 6 | "Enter": "היכנס", 7 | "Newer": "חדש יותר", 8 | "Older": "ישן יותר", 9 | "Posts": "פוסטים", 10 | "Publish to...": "פירסום ל...", 11 | "Publish": "פירסום", 12 | "Read more...": "קרא עוד...", 13 | "Subscribe": "הירשם", 14 | "This blog requires a password.": "לבלוג זה ישנה סיסמה", 15 | "Toggle theme": "החלפת ערכת נושא", 16 | "View posts": "צפייה בפוסטים", 17 | "delete": "מחיקה", 18 | "edit": "עריכה", 19 | "email subscription prompt": "הכנס את המייל שלך כדי לקבל לעידכונים", 20 | "move to...": "העברה ל...", 21 | "pin": "הצמדה", 22 | "published with write.as": "פורסם ע''י write.as", 23 | "share modal ending": "שלח לחברים, פרסם ברשת, או אולי צייץ את זה. למד עוד.", 24 | "share modal introduction": "לכל פוסט יש קישור סודי ויחודי שניתן לשתף עם כל אחד. הנה הקישור:", 25 | "share modal title": "שיתוף פוסט זה", 26 | "share": "שיתוף", 27 | "unpin": "ביטול הצמדה", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_hu.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesHU = map[string]string{ 4 | "Anonymous post": "Névtelen bejegyzés", 5 | "Blogs": "Blogok", 6 | "Newer": "Újabb", 7 | "Older": "Régebbi", 8 | "Posts": "Bejegyzések", 9 | "Publish to...": "Publikálja ide...", 10 | "Publish": "Publikálás", 11 | "Read more...": "Olvass tovább...", 12 | "Toggle theme": "Szín váltása", 13 | "View posts": "Bejegyzések megtekintése", 14 | "delete": "törlés", 15 | "edit": "szerkesztés", 16 | "move to...": "költözd át...", 17 | "pin": "rögzítés", 18 | "published with write.as": "közzétett write.as segitségével", 19 | "share modal ending": "Küldje el egy ismerősnek, ossza meg az interneten keresztül, akár Twitteren. További információ:", 20 | "share modal introduction": "Minden közzétett bejegyzés egy titkos, egyedi URL-címet kap, amit maga megoszthat bárkivel. Az URL-cím:", 21 | "share modal title": "Ossza meg ezt a bejegyzést", 22 | "share": "megosztás", 23 | "unpin": "rögzítés felbontása", 24 | } 25 | -------------------------------------------------------------------------------- /l10n/phrases_it.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesIT = map[string]string{ 4 | "Anonymous post": "Post anonimo", 5 | "Blogs": "Blogs", 6 | "Enter": "Invio", 7 | "Newer": "Recenti", 8 | "Older": "Precedenti", 9 | "Posts": "Posts", 10 | "Publish to...": "Pubblica su...", 11 | "Publish": "Pubblica", 12 | "Read more...": "Continua...", 13 | "Subscribe": "Sottoscrivi", 14 | "This blog requires a password.": "Questo blog necessita una password.", 15 | "Toggle theme": "Attiva tema", 16 | "View posts": "Vedi Posts", 17 | "delete": "cancella", 18 | "edit": "modifica", 19 | "email subscription prompt": "Inserisci la tua email per ricevere aggiornamenti", 20 | "move to...": "sposta verso...", 21 | "pin": "appunta", 22 | "published with write.as": "pubblicato con write.as", 23 | "share modal ending": "Mandala ad un amico, condividila sul web, oppure tweettala. Per saperne di più.", 24 | "share modal introduction": "Ogni post pubblicato possiede una URL segreta e unica che puoi condividere con chiunque. Questa è l'URL:", 25 | "share modal title": "Condividi questo post", 26 | "share": "condividi", 27 | "unpin": "stacca", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_ja.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesJA = map[string]string{ 4 | "Anonymous post": "匿名投稿", 5 | "Blogs": "ブログ", 6 | "Enter": "認証", 7 | "Newer": "新しい投稿", 8 | "Older": "古い投稿", 9 | "Posts": "投稿", 10 | "Publish to...": "公開先…", 11 | "Publish": "公開", 12 | "Read more...": "もっと読む…", 13 | "Subscribe": "購読", 14 | "This blog requires a password.": "このブログはパスワードを必要としています。", 15 | "Toggle theme": "テーマを変更", 16 | "View posts": "投稿を見る", 17 | "delete": "削除", 18 | "edit": "編集", 19 | "email subscription prompt": "このブログを購読したい場合は、メールアドレスを入力してください。", 20 | "move to...": "移動…", 21 | "pin": "固定表示する", 22 | "published with write.as": "write.as を使って公開されました", 23 | "share modal ending": "友達に送信したり、Web 上で共有したり、ツイートすることが出来ます。もっと詳しく。", 24 | "share modal introduction": "全ての投稿には秘密の、シェアするとだれでも見ることのできる固有の URL があります。これがその URL です:", 25 | "share modal title": "投稿をシェアする", 26 | "share": "シェア", 27 | "unpin": "固定表示をやめる", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_ko.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | // Temporal korean localation by Hoto Ras (@ras@hoto.moe), 2024 4 | var phrasesKO = map[string]string{ 5 | "Anonymous post": "모두의 게시물", 6 | "Blogs": "블로그", 7 | "Enter": "접속", 8 | "Newer": "신규", 9 | "Next": "다음", 10 | "Older": "오래됨", 11 | "Posts": "게시물", 12 | "Previous": "이전", 13 | "Publish to...": "다음으로 게시...", 14 | "Publish": "게시", 15 | "Read more...": "더 읽어보기...", 16 | "Subscribe": "구독", 17 | "This blog requires a password.": "이 블로그는 비밀번호가 필요합니다.", 18 | "Toggle theme": "테마 토글", 19 | "View posts": "게시물 보기", 20 | "delete": "삭제", 21 | "edit": "수정", 22 | "email subscription confirm": "이메일을 확인하고 확인 링크를 눌러 구독을 완료하세요.", 23 | "email subscription prompt": "새로운 소식을 보고 싶으신가요? 여기에 이메일을 입력해 구독하세요!", 24 | "email subscription success": "구독이 완료되었습니다! 이제 앞으로의 블로그 게시물을 이메일로 받아볼 수 있어요.", 25 | "move to...": "다음으로 이동...", 26 | "pin": "고정", 27 | "published with write.as": "write.as를 통해 배포됨", 28 | "share modal ending": "친구에게 보내거나, 인터넷에 공유하거나, 트윗할 수도 있습니다. 더 알아보기", 29 | "share modal introduction": "각각의 게시물에는 비밀이 있어요. 각자 특별한 주소가 있어 모두와 공유할 수 있답니다. 주소는 여기 있어요:", 30 | "share modal title": "이 게시물 공유하기", 31 | "share": "공유", 32 | "unpin": "고정 해제", 33 | "title dash": "—", 34 | } 35 | -------------------------------------------------------------------------------- /l10n/phrases_lt.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesLT = map[string]string{ 4 | "Anonymous post": "Anoniminis įrašas", 5 | "Blogs": "Tinklaraščiai", 6 | "Enter": "Įvesti", 7 | "Newer": "Naujesni", 8 | "Older": "Senesni", 9 | "Posts": "Įrašai", 10 | "Publish to...": "Publikuoti į...", 11 | "Publish": "Publikuoti", 12 | "Read more...": "Skaityti daugiau...", 13 | "This blog requires a password.": "Šis tinklaraštis reikalauja slaptažodžio.", 14 | "Toggle theme": "Keisti temą", 15 | "View posts": "Peržiūrėti įrašus", 16 | "delete": "ištrinti", 17 | "edit": "koreguoti", 18 | "move to...": "perkelti į...", 19 | "pin": "prisegti", 20 | "published with write.as": "publikuojama su write.as", 21 | "share modal ending": "Siųskite draugui, dalinkitės internete ar per Twitter. Sužinokite daugiau.", 22 | "share modal introduction": "Kiekvienas publikuotas įrašas turi slaptą, unikalų URL kuriuo galite pasidalinti su bet kuo. Šis URL yra:", 23 | "share modal title": "Dalintis šiuo įrašu", 24 | "share": "dalintis", 25 | "unpin": "atsegti", 26 | "title dash": "–", 27 | } 28 | -------------------------------------------------------------------------------- /l10n/phrases_mk.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesMK = map[string]string{ 4 | "Anonymous post": "Анонимен напис", 5 | "Blogs": "Блогови", 6 | "Enter": "Влез", 7 | "Newer": "Понови", 8 | "Older": "Постари", 9 | "Posts": "Написи", 10 | "Publish to...": "Објави во...", 11 | "Publish": "Објави", 12 | "Read more...": "Прочитај повеќе...", 13 | "This blog requires a password.": "Овој блог бара лозинка.", 14 | "Toggle theme": "Вкчучи тема", 15 | "View posts": "Види Написи", 16 | "delete": "избриши", 17 | "edit": "измени", 18 | "move to...": "премести во...", 19 | "pin": "закачи", 20 | "published with write.as": "Објавено со write.as", 21 | "share modal ending": "Испрати го на пријател, сподели преку интернет, или можеби твитувај. Научи повеќе.", 22 | "share modal introduction": "Секој објавен напис има тајна, уникатна URL-адреса која можете да ја споделите со секого. Ова е таа URL адреса:", 23 | "share modal title": "Сподели го овој напис", 24 | "share": "сподели", 25 | "unpin": "откачи", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_nl.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesNL = map[string]string{ 4 | "Anonymous post": "Anoniem artikel", 5 | "Blogs": "Blogs", 6 | "Enter": "Invoeren", 7 | "Newer": "Nieuwer", 8 | "Older": "Ouder", 9 | "Posts": "Artikelen", 10 | "Publish to...": "Publiceren op...", 11 | "Publish": "Publiceren", 12 | "Read more...": "Lees verder...", 13 | "Subscribe": "Abonneren", 14 | "This blog requires a password.": "Voor dit blog is een wachtwoord vereist.", 15 | "Toggle theme": "Ander thema", 16 | "View posts": "Artikelen bekijken", 17 | "delete": "verwijderen", 18 | "edit": "bewerken", 19 | "email subscription prompt": "Voer je e-mailadres in om te abonneren op updates.", 20 | "move to...": "verplaatsen naar...", 21 | "pin": "vastmaken", 22 | "published with write.as": "gepubliceerd met write.as", 23 | "share modal ending": "Deel de link met een vriend, op het internet of op Twitter. Meer informatie.", 24 | "share modal introduction": "Elk gepubliceerd artikel bevat een geheime, unieke link die je met iedereen kunt delen. Dit is de link:", 25 | "share modal title": "Deel dit artikel", 26 | "share": "delen", 27 | "unpin": "losmaken", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/phrases_pl.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesPL = map[string]string{ 4 | "Anonymous post": "Anonimowy post", 5 | "Blogs": "Blogi", 6 | "Enter": "Wejdź", 7 | "Newer": "Nowsze", 8 | "Older": "Starsze", 9 | "Posts": "Posty", 10 | "Publish to...": "Opublikuj w...", 11 | "Publish": "Opublikuj", 12 | "Read more...": "Czytaj dalej...", 13 | "This blog requires a password.": "Dostęp do tego bloga wymaga hasła.", 14 | "Toggle theme": "Zmień motyw", 15 | "View posts": "Pokaż teksty", 16 | "delete": "usuń", 17 | "edit": "edytuj", 18 | "move to...": "przenieś do...", 19 | "pin": "przypnij", 20 | "published with write.as": "opublkowano dzięki write.as", 21 | "share modal ending": "Wyślij go znajomemu, udostępnij w sieci, albo wstaw na twittera. Dowiedz się więcej.", 22 | "share modal introduction": "Każdy opublikowany post ma sekretny, unikalny adres URL, którym możesz podzielić się z kimkolwiek chcesz. Oto on:", 23 | "share modal title": "Udostępnij ten post", 24 | "share": "udostępnij", 25 | "unpin": "odepnij", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_pt.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesPT = map[string]string{ 4 | "Anonymous post": "Postagem anônima", 5 | "Blogs": "Blogs", 6 | "Enter": "Entrar", 7 | "Newer": "Próximas", 8 | "Older": "Anteriores", 9 | "Posts": "Postagens", 10 | "Publish to...": "Publicar para...", 11 | "Publish": "Publicar", 12 | "Read more...": "Leia mais...", 13 | "This blog requires a password.": "Este blog requer uma senha.", 14 | "Toggle theme": "Alterar o tema", 15 | "View posts": "Ver Postagens", 16 | "delete": "apagar", 17 | "edit": "edite", 18 | "move to...": "mover para...", 19 | "pin": "fixar", 20 | "published with write.as": "publicado com write.as", 21 | "share modal ending": "Envie para um amigo, compartilhe na Web ou talvez um tweet. Saiba mais.", 22 | "share modal introduction": "Cada postagem publicada tem um URL exclusivo e secreto que você pode compartilhar com qualquer pessoa. Este é o URL:", 23 | "share modal title": "Compartilhar esta postagem", 24 | "share": "compartilhar", 25 | "unpin": "soltar", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_ro.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesRO = map[string]string{ 4 | "Anonymous post": "Postare anonimă", 5 | "Blogs": "Bloguri", 6 | "Newer": "Mai noi", 7 | "Older": "Mai vechi", 8 | "Posts": "Postări", 9 | "Publish to...": "Publică pe...", 10 | "Publish": "Publică", 11 | "Read more...": "Citește mai departe...", 12 | "Toggle theme": "Comutare culori", 13 | "View posts": "Vizionează postările publlicate", 14 | "delete": "șterge", 15 | "edit": "editează", 16 | "move to...": "mută la...", 17 | "pin": "fixează", 18 | "published with write.as": "publicat cu write.as", 19 | "share modal ending": "Trimite-o unui prieten, posteazăl oriunde pe net, chiar și pe Twitter. Aflați mai multe.", 20 | "share modal introduction": "Fiecare postare publicată are o adresă URL secretă, unică, pe care o puteți partaja cu oricine. Aceasta este adresa URL:", 21 | "share modal title": "Distribuie această postare", 22 | "share": "distribuie", 23 | "unpin": "anulează fixarea", 24 | } 25 | -------------------------------------------------------------------------------- /l10n/phrases_ru.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesRU = map[string]string{ 4 | "Anonymous post": "Анонимная запись", 5 | "Blogs": "Блоги", 6 | "Enter": "Войти", 7 | "Newer": "Новее", 8 | "Older": "Старее", 9 | "Posts": "Записи", 10 | "Publish to...": "Опубликовать в...", 11 | "Publish": "Опубликовать", 12 | "Read more...": "Читать дальше...", 13 | "This blog requires a password.": "Для этого блога нужен пароль.", 14 | "Toggle theme": "Переключить тему", 15 | "View posts": "Посмотреть записи", 16 | "delete": "удалить", 17 | "edit": "редактировать", 18 | "move to...": "переместить в...", 19 | "pin": "закрепить", 20 | "published with write.as": "опубликовано с помощью write.as", 21 | "share modal ending": "Отправить другу, поделиться с интернетом, или возможно твитнуть. Узнать больше.", 22 | "share modal introduction": "У каждой опубликованной записи есть секретный, уникальный URL, которым можно поделиться с кем угодно. Этот URL:", 23 | "share modal title": "Поделиться записью", 24 | "share": "поделиться", 25 | "unpin": "открепить", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_sk.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesSK = map[string]string{ 4 | "Anonymous post": "Anonymný príspevok", 5 | "Blogs": "Blogy", 6 | "Enter": "Vstúp", 7 | "Newer": "Novšie", 8 | "Older": "Staršie", 9 | "Posts": "Príspevky", 10 | "Publish to...": "Publikuj do...", 11 | "Publish": "Publikuj", 12 | "Read more...": "Čítaj viac...", 13 | "This blog requires a password.": "Tento blog vyžaduje heslo.", 14 | "Toggle theme": "Zmeň motív", 15 | "View posts": "Zobraz príspevky", 16 | "delete": "vymaž", 17 | "edit": "uprav", 18 | "move to...": "presuň do...", 19 | "pin": "pripni", 20 | "published with write.as": "publikované s write.as", 21 | "share modal ending": "Pošli ju kamarátovi, zdieľaj ju naprieč webom alebo ju tweetni. Dozvi sa viac.", 22 | "share modal introduction": "Každý publikovaný príspevok má utajenú, jedinečnú adresu URL, ktorú môžeš s kýmkoľvek zdieľať. Toto je ona:", 23 | "share modal title": "Zdieľaj tento príspevok", 24 | "share": "zdieľaj", 25 | "unpin": "odopni", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_sv.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesSV = map[string]string{ 4 | "Anonymous post": "Anonymt inlägg", 5 | "Blogs": "Bloggar", 6 | "Enter": "Visa", 7 | "Newer": "Nyare", 8 | "Older": "Äldre", 9 | "Posts": "Inlägg", 10 | "Publish to...": "Publicera till...", 11 | "Publish": "Publicera", 12 | "Read more...": "Läs mer...", 13 | "This blog requires a password.": "Denna blogg kräver ett lösenord.", 14 | "Toggle theme": "Växla tema", 15 | "View posts": "Se Inlägg", 16 | "delete": "radera", 17 | "edit": "redigera", 18 | "move to...": "flytta till...", 19 | "pin": "fäst", 20 | "published with write.as": "publicerad med write.as", 21 | "share modal ending": "Skicka inlägget till en vän, dela det över webben, eller kanske tweeta det. Läs mer.", 22 | "share modal introduction": "Varje publicerat inlägg har en hemlig, unik webbadress du kan dela med vem som helst. Detta är den webbadressen:", 23 | "share modal title": "Dela detta inlägg", 24 | "share": "dela", 25 | "unpin": "lossa", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_tg.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesTG = map[string]string{ 4 | "Anonymous post": "Почтаи пинҳонӣ", 5 | "Blogs": "Блогҳо", 6 | "Enter": "Дохил", 7 | "Newer": "Баъдтар", 8 | "Older": "Пеш аз он", 9 | "Posts": "Мундариҷа", 10 | "Publish to...": "нашр дар...", 11 | "Publish": "Нашр", 12 | "Read more...": "Бештар бихонед...", 13 | "This blog requires a password.": "Ин блог бояд паролро талаб кунад.", 14 | "Toggle theme": "тағйир намои", 15 | "View posts": "Намоиши мундариҷа", 16 | "delete": "Нест кардан", 17 | "edit": "Таҳрири", 18 | "move to...": "Гузариш ба", 19 | "pin": "пин", 20 | "published with write.as": "бо нашр шуд аз write.as", 21 | "share modal ending": "Ба дӯстонатон фиристед, дар веб ё мубоҳиса дар бораи он мубодила кунед. Бештар омӯзед.", 22 | "share modal introduction": "Ҳар як почтаи дорои адреси махфие, ки шумо метавонед бо дигарон мубодила кунед. Ин суроға:", 23 | "share modal title": "Ба шарикиӣ ин мундариҷа", 24 | "share": "Ба шарикӣ", 25 | "unpin": "Аз пин хабардори", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_tr.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesTR = map[string]string{ 4 | "Anonymous post": "Anonim gönderi", 5 | "Blogs": "Bloglar", 6 | "Enter": "Gir", 7 | "Newer": "Yeniler", 8 | "Older": "Eskiler", 9 | "Posts": "Gönderiler", 10 | "Publish to...": "Yayınla...", 11 | "Publish": "Yayınla", 12 | "Read more...": "Devamını oku...", 13 | "This blog requires a password.": "Bu blog parola korumalıdır.", 14 | "Toggle theme": "Temayı göster", 15 | "View posts": "Gönderilere Bak", 16 | "delete": "sil", 17 | "edit": "düzenle", 18 | "move to...": "taşı", 19 | "pin": "sabitle", 20 | "published with write.as": "write.as ile yayınlandı", 21 | "share modal ending": "Bir dosta gönder, internet üzerinden paylaş veya sadece tweetle. Daha fazla öğren.", 22 | "share modal introduction": "Tüm yayınlanmış gönderilerin, onları paylaşmaya yarayan gizli, benzersiz bir bağlantısı vardır. Bağlantı:", 23 | "share modal title": "Bu gönderiyi paylaş", 24 | "share": "paylaş", 25 | "unpin": "sabitlemeyi kaldır", 26 | } 27 | -------------------------------------------------------------------------------- /l10n/phrases_zh.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | var phrasesZH = map[string]string{ 4 | "Anonymous post": "匿名文章", 5 | "Blogs": "博客", 6 | "Enter": "进入", 7 | "Newer": "最近的博客", 8 | "Older": "之前的博客", 9 | "Posts": "文章", 10 | "Publish to...": "发布到...", 11 | "Publish": "发布", 12 | "Read more...": "阅读更多", 13 | "Subscribe": "订阅", 14 | "This blog requires a password.": "这篇博客需要密码", 15 | "Toggle theme": "更换主题", 16 | "View posts": "查看文章", 17 | "delete": "删除", 18 | "edit": "编辑", 19 | "email subscription prompt": "输入邮件地址,订阅更新", 20 | "move to...": "移动到", 21 | "pin": "固定文章", 22 | "published with write.as": "用write.as来发布", 23 | "share modal ending": "发送给朋友,或者线上分享,如果可能的话,分享到推特上。了解更多。", 24 | "share modal introduction": "发布的每篇文章都有一个唯一的私有URL,你可以把它分享给任何人。这是URL:", 25 | "share modal title": "分享这篇文章", 26 | "share": "分享", 27 | "unpin": "取消固定", 28 | } 29 | -------------------------------------------------------------------------------- /l10n/strings.go: -------------------------------------------------------------------------------- 1 | package l10n 2 | 3 | // Strings returns a translation set that will take any term and return its 4 | // translation. 5 | func Strings(lang string) map[string]string { 6 | switch lang { 7 | case "ar": 8 | return phrasesAR 9 | case "cs": 10 | return phrasesCS 11 | case "da": 12 | return phrasesDA 13 | case "de": 14 | return phrasesDE 15 | case "el": 16 | return phrasesEL 17 | case "eo": 18 | return phrasesEO 19 | case "es": 20 | return phrasesES 21 | case "eu": 22 | return phrasesEU 23 | case "fa": 24 | return phrasesFA 25 | case "fr": 26 | return phrasesFR 27 | case "gl": 28 | return phrasesGL 29 | case "he": 30 | return phrasesHE 31 | case "hu": 32 | return phrasesHU 33 | case "it": 34 | return phrasesIT 35 | case "ja": 36 | return phrasesJA 37 | case "ko": 38 | return phrasesKO 39 | case "lt": 40 | return phrasesLT 41 | case "mk": 42 | return phrasesMK 43 | case "nl": 44 | return phrasesNL 45 | case "pl": 46 | return phrasesPL 47 | case "pt": 48 | return phrasesPT 49 | case "ro": 50 | return phrasesRO 51 | case "ru": 52 | return phrasesRU 53 | case "sk": 54 | return phrasesSK 55 | case "sv": 56 | return phrasesSV 57 | case "tg": 58 | return phrasesTG 59 | case "tr": 60 | return phrasesTR 61 | case "zh": 62 | return phrasesZH 63 | default: 64 | return phrases 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | // Package log prints different kinds of log messages in the formats we like. 2 | package log 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "os" 8 | "runtime" 9 | ) 10 | 11 | var ( 12 | InfoLog *log.Logger 13 | ErrorLog *log.Logger 14 | ) 15 | 16 | func init() { 17 | InfoLog = log.New(os.Stdout, "", log.Ldate|log.Ltime) 18 | ErrorLog = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime) 19 | } 20 | 21 | // Info logs an informational message to Stdout. 22 | func Info(s string, v ...interface{}) { 23 | InfoLog.Printf(s, v...) 24 | } 25 | 26 | // Error logs an error to Stderr. 27 | func Error(s string, v ...interface{}) { 28 | // Include original caller information 29 | _, file, line, _ := runtime.Caller(1) 30 | 31 | // Determine short filename (from standard log package) 32 | short := file 33 | for i := len(file) - 1; i > 0; i-- { 34 | if file[i] == '/' { 35 | short = file[i+1:] 36 | break 37 | } 38 | } 39 | file = short 40 | 41 | ErrorLog.Printf(fmt.Sprintf("%s:%d: ", short, line)+s, v...) 42 | } 43 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | var postReg = regexp.MustCompile("(.*(/|id=))[a-zA-Z0-9]{12,13}(.*)") 8 | var tokenReg = regexp.MustCompile("(.*t=)[a-zA-Z0-9]{32}(.*)") 9 | 10 | func ScrubID(uri string) string { 11 | curStr := postReg.ReplaceAllString(uri, "$1[scrubbed]$3") 12 | curStr = tokenReg.ReplaceAllString(curStr, "$1[scrubbed]$2") 13 | return curStr 14 | } 15 | -------------------------------------------------------------------------------- /logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type scrubTest struct { 8 | Input string 9 | Expected string 10 | } 11 | 12 | var uris = []scrubTest{ 13 | scrubTest{Input: "/1234567890123", Expected: "/[scrubbed]"}, 14 | scrubTest{Input: "/acnsd8ndsklao", Expected: "/[scrubbed]"}, 15 | scrubTest{Input: "/ACNSD8NDSKLAO", Expected: "/[scrubbed]"}, 16 | scrubTest{Input: "/acNsD8NdSKlaO", Expected: "/[scrubbed]"}, 17 | scrubTest{Input: "/acNsD8NdSKlaO/embed", Expected: "/[scrubbed]/embed"}, 18 | scrubTest{Input: "/acNsD8NdSKlaO/embed/", Expected: "/[scrubbed]/embed/"}, 19 | scrubTest{Input: "/acNsD8NdSKlaO/embed/data.js", Expected: "/[scrubbed]/embed/data.js"}, 20 | scrubTest{Input: "/acnsd8ndsklao.txt", Expected: "/[scrubbed].txt"}, 21 | scrubTest{Input: "/8sj2kkjsn192.json", Expected: "/[scrubbed].json"}, 22 | scrubTest{Input: "/acnsd8Ndsklao", Expected: "/[scrubbed]"}, 23 | scrubTest{Input: "/12345678901", Expected: "/12345678901"}, 24 | scrubTest{Input: "GET /8s9dja0vjbklj", Expected: "GET /[scrubbed]"}, 25 | scrubTest{Input: "POST /8s9dja0vjbklj?delete=true", Expected: "POST /[scrubbed]?delete=true"}, 26 | scrubTest{Input: "GET /8s9dja0vjbkl", Expected: "GET /[scrubbed]"}, 27 | scrubTest{Input: "GET /asdf90as.txt", Expected: "GET /asdf90as.txt"}, 28 | scrubTest{Input: "GET /api/999999999999", Expected: "GET /api/[scrubbed]"}, 29 | scrubTest{Input: "DELETE /api/?id=8s9dja0vjbkl&t=123456789012345678901234567890ab", Expected: "DELETE /api/?id=[scrubbed]&t=[scrubbed]"}, 30 | scrubTest{Input: "DELETE /api/8s9dja0vjbkl?t=123456789012345678901234567890ab", Expected: "DELETE /api/[scrubbed]?t=[scrubbed]"}, 31 | } 32 | 33 | func TestScrubID(t *testing.T) { 34 | var scrubRes string 35 | 36 | for i := range uris { 37 | scrubRes = ScrubID(uris[i].Input) 38 | if scrubRes != uris[i].Expected { 39 | t.Errorf("#%d got %v, expected %v", i, scrubRes, uris[i].Expected) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /memo/memo.go: -------------------------------------------------------------------------------- 1 | package memo 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type ( 9 | Memo struct { 10 | f Func 11 | mu sync.Mutex //guards cache 12 | cache *entry 13 | 14 | cacheDur time.Duration 15 | lastCache time.Time 16 | } 17 | 18 | Func func() (interface{}, error) 19 | ) 20 | 21 | type ( 22 | entry struct { 23 | res result 24 | ready chan struct{} // close when res is read 25 | } 26 | 27 | result struct { 28 | value interface{} 29 | err error 30 | } 31 | ) 32 | 33 | func New(f Func, cd time.Duration) *Memo { 34 | return &Memo{ 35 | f: f, 36 | cacheDur: cd, 37 | } 38 | } 39 | 40 | // Invalidate resets the cache to nil if the memo's given cache duration has 41 | // elapsed, and returns true if the cache was actually invalidated. 42 | func (memo *Memo) Invalidate() bool { 43 | defer memo.mu.Unlock() 44 | memo.mu.Lock() 45 | 46 | if memo.cache != nil && time.Now().Sub(memo.lastCache) > memo.cacheDur { 47 | memo.cache = nil 48 | memo.lastCache = time.Now() 49 | return true 50 | } 51 | return false 52 | } 53 | 54 | func (memo *Memo) Get() (value interface{}, err error) { 55 | memo.mu.Lock() 56 | if memo.cache == nil { 57 | memo.cache = &entry{ready: make(chan struct{})} 58 | memo.mu.Unlock() 59 | 60 | memo.cache.res.value, memo.cache.res.err = memo.f() 61 | 62 | close(memo.cache.ready) 63 | } else { 64 | memo.mu.Unlock() 65 | 66 | <-memo.cache.ready 67 | } 68 | return memo.cache.res.value, memo.cache.res.err 69 | } 70 | 71 | // Reset forcibly resets the cache to nil without checking if the cache 72 | // duration has elapsed. 73 | func (memo *Memo) Reset() { 74 | defer memo.mu.Unlock() 75 | memo.mu.Lock() 76 | 77 | memo.cache = nil 78 | memo.lastCache = time.Now() 79 | } 80 | -------------------------------------------------------------------------------- /passgen/passgen.go: -------------------------------------------------------------------------------- 1 | // Package passgen generates random passwords. 2 | // 3 | // Example usage: 4 | // 5 | // p := passgen.New() // p is "6NX(W`GD]4:Tqk};Y@A-" 6 | // 7 | // Logic originally from dchest's uniuri library: 8 | // https://github.com/dchest/uniuri 9 | // 10 | // Functions read from crypto/rand random source, and panic if they fail to 11 | // read from it. 12 | package passgen 13 | 14 | import "crypto/rand" 15 | 16 | // DefLen is the default password length returned. 17 | const DefLen = 20 18 | 19 | // DefChars is the default set of characters used in the password. 20 | var DefChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()- _=+,.?/:;{}[]`~") 21 | 22 | // New returns a random, unmemorable password of the default length with the 23 | // default set of characters. 24 | func New() string { 25 | return NewLenChars(DefLen, DefChars) 26 | } 27 | 28 | // NewLen returns a random, unmemorable password of the given length with the 29 | // default set of characters. 30 | func NewLen(length int) string { 31 | return NewLenChars(length, DefChars) 32 | } 33 | 34 | // NewLenChars returns a random, unmemorable password of the given length with 35 | // the given set of characters. 36 | func NewLenChars(length int, chars []byte) string { 37 | if length == 0 { 38 | return "" 39 | } 40 | clen := len(chars) 41 | maxrb := 255 - (256 % clen) 42 | b := make([]byte, length) 43 | r := make([]byte, length+(length/4)) // storage for random bytes. 44 | i := 0 45 | for { 46 | if _, err := rand.Read(r); err != nil { 47 | panic("passgen: error reading random bytes: " + err.Error()) 48 | } 49 | for _, rb := range r { 50 | c := int(rb) 51 | if c > maxrb { 52 | // Skip this number to avoid modulo bias. 53 | continue 54 | } 55 | b[i] = chars[c%clen] 56 | i++ 57 | if i == length { 58 | return string(b) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /passgen/wordish.go: -------------------------------------------------------------------------------- 1 | package passgen 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | var ( 9 | ar = []rune("aA4") 10 | cr = []rune("cC") 11 | er = []rune("eE3") 12 | fr = []rune("fF") 13 | gr = []rune("gG") 14 | hr = []rune("hH") 15 | ir = []rune("iI1") 16 | lr = []rune("lL") 17 | nr = []rune("nN") 18 | or = []rune("oO0") 19 | rr = []rune("rR") 20 | sr = []rune("sS5") 21 | tr = []rune("tT7") 22 | remr = []rune("bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ0123456789") 23 | ) 24 | 25 | // NewWordish generates a password made of word-like words. 26 | func NewWordish() string { 27 | b := []rune{} 28 | b = append(b, randLetter(cr)) 29 | b = append(b, randLetter(hr)) 30 | b = append(b, randLetter(ar)) 31 | b = append(b, randLetter(nr)) 32 | b = append(b, randLetter(gr)) 33 | b = append(b, randLetter(er)) 34 | b = append(b, randLetter(tr)) 35 | b = append(b, randLetter(hr)) 36 | b = append(b, randLetter(ir)) 37 | b = append(b, randLetter(sr)) 38 | b = append(b, randLetter(ar)) 39 | b = append(b, randLetter(fr)) 40 | b = append(b, randLetter(tr)) 41 | b = append(b, randLetter(er)) 42 | b = append(b, randLetter(rr)) 43 | b = append(b, randLetter(lr)) 44 | b = append(b, randLetter(or)) 45 | b = append(b, randLetter(gr)) 46 | b = append(b, randLetter(gr)) 47 | b = append(b, randLetter(ir)) 48 | b = append(b, randLetter(nr)) 49 | b = append(b, randLetter(gr)) 50 | b = append(b, randLetter(ir)) 51 | b = append(b, randLetter(nr)) 52 | for i := 0; i <= 7; i++ { 53 | b = append(b, randLetter(remr)) 54 | } 55 | return string(b) 56 | } 57 | 58 | func randLetter(l []rune) rune { 59 | li, err := rand.Int(rand.Reader, big.NewInt(int64(len(l)))) 60 | if err != nil { 61 | return rune(-1) 62 | } 63 | return l[li.Int64()] 64 | } 65 | -------------------------------------------------------------------------------- /posts/parse.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "fmt" 5 | stripmd "github.com/writeas/go-strip-markdown/v2" 6 | "github.com/writeas/slug" 7 | "github.com/writeas/web-core/stringmanip" 8 | "regexp" 9 | "strings" 10 | "unicode" 11 | "unicode/utf8" 12 | ) 13 | 14 | const ( 15 | maxTitleLen = 80 16 | assumedTitleLen = 80 17 | ) 18 | 19 | var ( 20 | titleElementReg = regexp.MustCompile("") 21 | urlReg = regexp.MustCompile("https?://") 22 | imgReg = regexp.MustCompile(`!\[([^]]+)\]\([^)]+\)`) 23 | ) 24 | 25 | // ExtractTitle takes the given raw post text and returns a title, if explicitly 26 | // provided, and a body. 27 | func ExtractTitle(content string) (title string, body string) { 28 | if hashIndex := strings.Index(content, "# "); hashIndex == 0 { 29 | eol := strings.IndexRune(content, '\n') 30 | // First line should start with # and end with \n 31 | if eol != -1 { 32 | body = strings.TrimLeft(content[eol:], " \t\n\r") 33 | title = content[len("# "):eol] 34 | return 35 | } 36 | } 37 | body = content 38 | return 39 | } 40 | 41 | func PostTitle(content, friendlyId string) string { 42 | content = StripHTMLWithoutEscaping(content) 43 | 44 | content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) 45 | eol := strings.IndexRune(content, '\n') 46 | blankLine := strings.Index(content, "\n\n") 47 | if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen { 48 | return strings.TrimSpace(content[:blankLine]) 49 | } else if utf8.RuneCountInString(content) <= maxTitleLen { 50 | return content 51 | } 52 | return friendlyId 53 | } 54 | 55 | func FriendlyPostTitle(content, friendlyId string) string { 56 | content = StripHTMLWithoutEscaping(content) 57 | 58 | content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) 59 | eol := strings.IndexRune(content, '\n') 60 | blankLine := strings.Index(content, "\n\n") 61 | if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen { 62 | return strings.TrimSpace(content[:blankLine]) 63 | } else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen { 64 | return content 65 | } 66 | 67 | title, truncd := TruncToWord(PostLede(content, true), maxTitleLen) 68 | if truncd { 69 | title += "..." 70 | } 71 | return title 72 | } 73 | 74 | // PostDescription generates a description based on the given post content, 75 | // title, and post ID. This doesn't consider a V2 post field, `title` when 76 | // choosing what to generate. In case a post has a title, this function will 77 | // fail, and logic should instead be implemented to skip this when there's no 78 | // title, like so: 79 | // 80 | // var desc string 81 | // if title == "" { 82 | // desc = PostDescription(content, title, friendlyId) 83 | // } else { 84 | // desc = ShortPostDescription(content) 85 | // } 86 | func PostDescription(content, title, friendlyId string) string { 87 | maxLen := 140 88 | 89 | if content == "" { 90 | content = "WriteFreely is a painless, simple, federated blogging platform." 91 | } else { 92 | fmtStr := "%s" 93 | truncation := 0 94 | if utf8.RuneCountInString(content) > maxLen { 95 | // Post is longer than the max description, so let's show a better description 96 | fmtStr = "%s..." 97 | truncation = 3 98 | } 99 | 100 | if title == friendlyId { 101 | // No specific title was found; simply truncate the post, starting at the beginning 102 | content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)) 103 | } else { 104 | // There was a title, so return a real description 105 | blankLine := strings.Index(content, "\n\n") 106 | if blankLine < 0 { 107 | blankLine = 0 108 | } 109 | truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation) 110 | contentNoNL := strings.Replace(truncd, "\n", " ", -1) 111 | content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL)) 112 | } 113 | } 114 | 115 | return content 116 | } 117 | 118 | func ShortPostDescription(content string) string { 119 | maxLen := 140 120 | fmtStr := "%s" 121 | truncation := 0 122 | if utf8.RuneCountInString(content) > maxLen { 123 | // Post is longer than the max description, so let's show a better description 124 | fmtStr = "%s..." 125 | truncation = 3 126 | } 127 | return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))) 128 | } 129 | 130 | // TruncToWord truncates the given text to the provided limit. 131 | func TruncToWord(s string, l int) (string, bool) { 132 | truncated := false 133 | c := []rune(s) 134 | if len(c) > l { 135 | truncated = true 136 | s = string(c[:l]) 137 | spaceIdx := strings.LastIndexByte(s, ' ') 138 | if spaceIdx > -1 { 139 | s = s[:spaceIdx] 140 | } 141 | } 142 | return s, truncated 143 | } 144 | 145 | // PostLede attempts to extract the first thought of the given post, generally 146 | // contained within the first line or sentence of text. 147 | func PostLede(t string, includePunc bool) string { 148 | // Adjust where we truncate if we want to include punctuation 149 | iAdj := 0 150 | if includePunc { 151 | iAdj = 1 152 | } 153 | 154 | // Find lede within first line of text 155 | nl := strings.IndexRune(t, '\n') 156 | if nl > -1 { 157 | t = t[:nl] 158 | } 159 | 160 | // Strip certain HTML tags 161 | t = titleElementReg.ReplaceAllString(t, "") 162 | 163 | // Strip URL protocols 164 | t = urlReg.ReplaceAllString(t, "") 165 | 166 | // Strip image URL, leaving only alt text 167 | t = imgReg.ReplaceAllString(t, " $1 ") 168 | 169 | // Find lede within first sentence 170 | punc := strings.Index(t, ". ") 171 | if punc > -1 { 172 | t = t[:punc+iAdj] 173 | } 174 | punc = stringmanip.IndexRune(t, '。') 175 | if punc > -1 { 176 | c := []rune(t) 177 | t = string(c[:punc+iAdj]) 178 | } 179 | 180 | return t 181 | } 182 | 183 | func GetSlug(title, lang string) string { 184 | return GetSlugFromPost("", title, lang) 185 | } 186 | 187 | func GetSlugFromPost(title, body, lang string) string { 188 | if title == "" { 189 | // Remove Markdown, so e.g. link URLs and image alt text don't make it into the slug 190 | body = strings.TrimSpace(stripmd.StripOptions(body, stripmd.Options{SkipImages: true})) 191 | title = PostTitle(body, body) 192 | } 193 | title = PostLede(title, false) 194 | // Truncate lede if needed 195 | title, _ = TruncToWord(title, maxTitleLen) 196 | var s string 197 | if lang != "" && len(lang) == 2 { 198 | s = slug.MakeLang(title, lang) 199 | } else { 200 | s = slug.Make(title) 201 | } 202 | 203 | // Transliteration may cause the slug to expand past the limit, so truncate again 204 | s, _ = TruncToWord(s, maxTitleLen) 205 | return strings.TrimFunc(s, func(r rune) bool { 206 | // TruncToWord doesn't respect words in a slug, since spaces are replaced 207 | // with hyphens. So remove any trailing hyphens. 208 | return r == '-' 209 | }) 210 | } 211 | -------------------------------------------------------------------------------- /posts/parse_test.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type titleTest struct { 8 | in, title, body string 9 | } 10 | 11 | func TestExtractTitle(t *testing.T) { 12 | tests := []titleTest{ 13 | {`# Hello World 14 | This is my post`, "Hello World", "This is my post"}, 15 | {"No title", "", "No title"}, 16 | {`Not explicit title 17 | 18 | It's not explicit. 19 | Yep.`, "", `Not explicit title 20 | 21 | It's not explicit. 22 | Yep.`}, 23 | {"# Only a title", "", "# Only a title"}, 24 | } 25 | 26 | for _, test := range tests { 27 | title, body := ExtractTitle(test.in) 28 | if title != test.title { 29 | t.Fatalf("Wanted title '%s', got '%s'", test.title, title) 30 | } 31 | if body != test.body { 32 | t.Fatalf("Wanted body '%s', got '%s'", test.body, body) 33 | } 34 | } 35 | } 36 | 37 | func TestPostLede(t *testing.T) { 38 | text := map[string]string{ 39 | "早安。跨出舒適圈,才能前往": "早安。", 40 | "早安。This is my post. It is great.": "早安。", 41 | "Hello. 早安。": "Hello.", 42 | "Sup? Everyone says punctuation is punctuation.": "Sup?", 43 | "Humans are humans, and society is full of good and bad actors. Technology, at the most fundamental level, is a neutral tool that can be used by either to meet any ends. ": "Humans are humans, and society is full of good and bad actors.", 44 | `Online Domino Is Must For Everyone 45 | 46 | Do you want to understand how to play poker online?`: "Online Domino Is Must For Everyone", 47 | `おはようございます 48 | 49 | 私は日本から帰ったばかりです。`: "おはようございます", 50 | "Hello, we say, おはよう. We say \"good morning\"": "Hello, we say, おはよう.", 51 | } 52 | 53 | c := 1 54 | for i, o := range text { 55 | if s := PostLede(i, true); s != o { 56 | t.Errorf("#%d: Got '%s' from '%s'; expected '%s'", c, s, i, o) 57 | } 58 | c++ 59 | } 60 | } 61 | 62 | func TestTruncToWord(t *testing.T) { 63 | text := map[string]string{ 64 | "Можливо, ми можемо використовувати інтернет-інструменти, щоб виготовити якийсь текст, який би міг бути і на, і в кінцевому підсумку, буде скорочено, тому що це тривало так довго.": "Можливо, ми можемо використовувати інтернет-інструменти, щоб виготовити якийсь", 65 | "早安。This is my post. It is great. It is a long post that is great that is a post that is great.": "早安。This is my post. It is great. It is a long post that is great that is a post", 66 | "Sup? Everyone says punctuation is punctuation.": "Sup? Everyone says punctuation is punctuation.", 67 | "I arrived in Japan six days ago. Tired from a 10-hour flight after a night-long layover in Calgary, I wandered wide-eyed around Narita airport looking for an ATM.": "I arrived in Japan six days ago. Tired from a 10-hour flight after a night-long", 68 | } 69 | 70 | c := 1 71 | for i, o := range text { 72 | if s, _ := TruncToWord(i, 80); s != o { 73 | t.Errorf("#%d: Got '%s' from '%s'; expected '%s'", c, s, i, o) 74 | } 75 | c++ 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /posts/render.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "github.com/microcosm-cc/bluemonday" 5 | "github.com/writeas/saturday" 6 | "html" 7 | "regexp" 8 | "strings" 9 | "unicode" 10 | ) 11 | 12 | var ( 13 | blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n") 14 | endBlockReg = regexp.MustCompile("\n") 15 | 16 | markeddownReg = regexp.MustCompile("

(.+)

") 17 | ) 18 | 19 | func ApplyMarkdown(data []byte) string { 20 | mdExtensions := 0 | 21 | blackfriday.EXTENSION_TABLES | 22 | blackfriday.EXTENSION_FENCED_CODE | 23 | blackfriday.EXTENSION_AUTOLINK | 24 | blackfriday.EXTENSION_STRIKETHROUGH | 25 | blackfriday.EXTENSION_SPACE_HEADERS | 26 | blackfriday.EXTENSION_HEADER_IDS 27 | htmlFlags := 0 | 28 | blackfriday.HTML_USE_SMARTYPANTS | 29 | blackfriday.HTML_SMARTYPANTS_DASHES 30 | 31 | // Generate Markdown 32 | md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) 33 | // Strip out bad HTML 34 | policy := bluemonday.UGCPolicy() 35 | policy.AllowAttrs("class", "id").Globally() 36 | outHTML := string(policy.SanitizeBytes(md)) 37 | // Strip newlines on certain block elements that render with them 38 | outHTML = blockReg.ReplaceAllString(outHTML, "<$1>") 39 | outHTML = endBlockReg.ReplaceAllString(outHTML, "") 40 | 41 | return outHTML 42 | } 43 | 44 | func ApplyBasicMarkdown(data []byte) string { 45 | mdExtensions := 0 | 46 | blackfriday.EXTENSION_STRIKETHROUGH | 47 | blackfriday.EXTENSION_SPACE_HEADERS | 48 | blackfriday.EXTENSION_HEADER_IDS 49 | htmlFlags := 0 | 50 | blackfriday.HTML_SKIP_HTML | 51 | blackfriday.HTML_USE_SMARTYPANTS | 52 | blackfriday.HTML_SMARTYPANTS_DASHES 53 | 54 | // Generate Markdown 55 | md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) 56 | // Strip out bad HTML 57 | policy := bluemonday.UGCPolicy() 58 | policy.AllowAttrs("class", "id").Globally() 59 | outHTML := string(policy.SanitizeBytes(md)) 60 | outHTML = markeddownReg.ReplaceAllString(outHTML, "$1") 61 | outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace) 62 | 63 | return outHTML 64 | } 65 | 66 | // ApplyBasicAccessibleMarkdown applies Markdown to the given data, rendering basic text formatting and preserving hard 67 | // line breaks in HTML. It is meant for formatting text in small, multi-line UI elements, like user profile biographies. 68 | func ApplyBasicAccessibleMarkdown(data []byte) string { 69 | mdExtensions := 0 | 70 | blackfriday.EXTENSION_STRIKETHROUGH | 71 | blackfriday.EXTENSION_SPACE_HEADERS | 72 | blackfriday.EXTENSION_HEADER_IDS | 73 | blackfriday.EXTENSION_HARD_LINE_BREAK 74 | htmlFlags := 0 | 75 | blackfriday.HTML_USE_SMARTYPANTS | 76 | blackfriday.HTML_USE_XHTML | 77 | blackfriday.HTML_SMARTYPANTS_DASHES 78 | 79 | // Generate Markdown 80 | md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) 81 | // Strip out bad HTML 82 | policy := bluemonday.UGCPolicy() 83 | policy.AllowAttrs("class", "id").Globally() 84 | policy.AllowAttrs("rel").OnElements("a") 85 | policy.RequireNoFollowOnLinks(false) 86 | outHTML := string(policy.SanitizeBytes(md)) 87 | // Strip surrounding

tags that blackfriday adds 88 | outHTML = markeddownReg.ReplaceAllString(outHTML, "$1") 89 | outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace) 90 | 91 | return outHTML 92 | } 93 | 94 | // StripHTMLWithoutEscaping strips HTML tags with bluemonday's StrictPolicy, then unescapes the HTML 95 | // entities added in by sanitizing the content. 96 | func StripHTMLWithoutEscaping(content string) string { 97 | return html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content)) 98 | } 99 | -------------------------------------------------------------------------------- /query/builder.go: -------------------------------------------------------------------------------- 1 | // Package query assists in building SQL queries. 2 | package query 3 | 4 | import ( 5 | "database/sql" 6 | ) 7 | 8 | type Update struct { 9 | Updates, Conditions string 10 | Params []interface{} 11 | 12 | sep string 13 | } 14 | 15 | func NewUpdate() *Update { 16 | return &Update{} 17 | } 18 | 19 | func (u *Update) Set(v, property string) *Update { 20 | if v != "" { 21 | u.Updates += u.sep + property + " = ?" 22 | u.sep = ", " 23 | u.Params = append(u.Params, v) 24 | } 25 | return u 26 | } 27 | 28 | func (u *Update) SetBytes(v []byte, property string) *Update { 29 | if len(v) > 0 { 30 | u.Updates += u.sep + property + " = ?" 31 | u.sep = ", " 32 | u.Params = append(u.Params, v) 33 | } 34 | return u 35 | } 36 | 37 | func (u *Update) SetStringPtr(v *string, property string) *Update { 38 | if v != nil { 39 | u.Updates += u.sep + property + " = ?" 40 | u.sep = ", " 41 | u.Params = append(u.Params, v) 42 | } 43 | return u 44 | } 45 | 46 | func (u *Update) SetIntPtr(v *int, property string) *Update { 47 | if v != nil { 48 | u.Updates += u.sep + property + " = ?" 49 | u.sep = ", " 50 | u.Params = append(u.Params, v) 51 | } 52 | return u 53 | } 54 | 55 | func (u *Update) SetBoolPtr(v *bool, property string) *Update { 56 | if v != nil { 57 | u.Updates += u.sep + property + " = ?" 58 | u.sep = ", " 59 | u.Params = append(u.Params, v) 60 | } 61 | return u 62 | } 63 | 64 | func (u *Update) SetNullBool(v *sql.NullBool, property string) *Update { 65 | if v != nil { 66 | u.Updates += u.sep + property + " = ?" 67 | u.sep = ", " 68 | u.Params = append(u.Params, v) 69 | } 70 | return u 71 | } 72 | 73 | func (u *Update) SetNullString(v *sql.NullString, property string) *Update { 74 | if v != nil { 75 | u.Updates += u.sep + property + " = ?" 76 | u.sep = ", " 77 | u.Params = append(u.Params, v) 78 | } 79 | return u 80 | } 81 | 82 | func (u *Update) Append(v interface{}) { 83 | u.Params = append(u.Params, v) 84 | } 85 | 86 | func (u *Update) Where(condition string, params ...interface{}) *Update { 87 | u.Conditions = condition 88 | for _, p := range params { 89 | u.Append(p) 90 | } 91 | return u 92 | } 93 | -------------------------------------------------------------------------------- /silobridge/silobridge.go: -------------------------------------------------------------------------------- 1 | package silobridge 2 | 3 | // fakeAPInstances contains a list of sites that we allow writers to mention 4 | // with the @handle@instance.tld syntax, plus the corresponding prefix to 5 | // insert between `https://instance.tld/` and `handle` (e.g. 6 | // https://medium.com/@handle) 7 | var fakeAPInstances = map[string]string{ 8 | "deviantart.com": "", 9 | "facebook.com": "", 10 | "flickr.com": "photos/", 11 | "github.com": "", 12 | "instagram.com": "", 13 | "medium.com": "@", 14 | "reddit.com": "user/", 15 | "twitter.com": "", 16 | "wattpad.com": "user/", 17 | "youtube.com": "user/", 18 | } 19 | 20 | // Profile returns the full profile URL for a fake ActivityPub instance, based 21 | // on the given handle and domain. If the domain isn't recognized, an empty 22 | // string is returned. 23 | func Profile(handle, domain string) string { 24 | prefix, ok := fakeAPInstances[domain] 25 | if !ok { 26 | return "" 27 | } 28 | return "https://" + domain + "/" + prefix + handle 29 | } 30 | -------------------------------------------------------------------------------- /stringmanip/runes.go: -------------------------------------------------------------------------------- 1 | package stringmanip 2 | 3 | /* 4 | The MIT License (MIT) 5 | 6 | Copyright (c) 2016 Sergey Kamardin 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | */ 26 | // Source: https://github.com/gobwas/glob/blob/master/util/runes/runes.go 27 | 28 | func IndexRune(str string, r rune) int { 29 | s := []rune(str) 30 | for i, c := range s { 31 | if c == r { 32 | return i 33 | } 34 | } 35 | return -1 36 | } 37 | 38 | func LastIndexRune(str string, needle rune) int { 39 | s := []rune(str) 40 | needles := []rune{needle} 41 | ls, ln := len(s), len(needles) 42 | 43 | switch { 44 | case ln == 0: 45 | if ls == 0 { 46 | return 0 47 | } 48 | return ls 49 | case ln == 1: 50 | return IndexLastRune(s, needles[0]) 51 | case ln == ls: 52 | if EqualRunes(s, needles) { 53 | return 0 54 | } 55 | return -1 56 | case ln > ls: 57 | return -1 58 | } 59 | 60 | head: 61 | for i := ls - 1; i >= 0 && i >= ln; i-- { 62 | for y := ln - 1; y >= 0; y-- { 63 | if s[i-(ln-y-1)] != needles[y] { 64 | continue head 65 | } 66 | } 67 | 68 | return i - ln + 1 69 | } 70 | 71 | return -1 72 | } 73 | 74 | func IndexLastRune(s []rune, r rune) int { 75 | for i := len(s) - 1; i >= 0; i-- { 76 | if s[i] == r { 77 | return i 78 | } 79 | } 80 | 81 | return -1 82 | } 83 | 84 | func EqualRunes(a, b []rune) bool { 85 | if len(a) == len(b) { 86 | for i := 0; i < len(a); i++ { 87 | if a[i] != b[i] { 88 | return false 89 | } 90 | } 91 | 92 | return true 93 | } 94 | 95 | return false 96 | } 97 | -------------------------------------------------------------------------------- /stringmanip/strings.go: -------------------------------------------------------------------------------- 1 | package stringmanip 2 | 3 | // Substring provides a safe way to extract a substring from a UTF-8 string. 4 | // From this discussion: 5 | // https://groups.google.com/d/msg/golang-nuts/cGq1Irv_5Vs/0SKoj49BsWQJ 6 | func Substring(s string, p, l int) string { 7 | if p < 0 || l <= 0 { 8 | return "" 9 | } 10 | c := []rune(s) 11 | if p > len(c) { 12 | return "" 13 | } else if p+l > len(c) || p+l < p { 14 | return string(c[p:]) 15 | } 16 | return string(c[p : p+l]) 17 | } 18 | -------------------------------------------------------------------------------- /tags/tags.go: -------------------------------------------------------------------------------- 1 | // Package tags supports operations around hashtags in plain text content 2 | package tags 3 | 4 | import ( 5 | "github.com/kylemcc/twitter-text-go/extract" 6 | ) 7 | 8 | // Extract finds all hashtags in the given string and returns a de-duplicated 9 | // list of them. 10 | func Extract(body string) []string { 11 | matches := extract.ExtractHashtags(body) 12 | tags := map[string]bool{} 13 | for i := range matches { 14 | // Second value (whether or not there's a hashtag) ignored here, since 15 | // we're only extracting hashtags. 16 | ht, _ := matches[i].Hashtag() 17 | tags[ht] = true 18 | } 19 | 20 | resTags := make([]string, 0) 21 | for k := range tags { 22 | resTags = append(resTags, k) 23 | } 24 | return resTags 25 | } 26 | --------------------------------------------------------------------------------