├── .deployment ├── .gitignore ├── .paket ├── Paket.Restore.targets ├── paket.exe └── paket.targets ├── Forge.toml ├── LICENSE ├── Procfile ├── README.md ├── build.cmd ├── build.fsx ├── build.sh ├── forge ├── paket.dependencies ├── paket.lock ├── script.fsx ├── src ├── FsTweet.Db.Migrations │ ├── FsTweet.Db.Migrations.fs │ ├── FsTweet.Db.Migrations.fsproj │ ├── Script.fsx │ └── paket.references └── FsTweet.Web │ ├── Auth.fs │ ├── Chessie.fs │ ├── Db.fs │ ├── Email.fs │ ├── FsTweet.Web.fs │ ├── FsTweet.Web.fsproj │ ├── Json.fs │ ├── Social.fs │ ├── Stream.fs │ ├── Tweet.fs │ ├── User.fs │ ├── UserProfile.fs │ ├── UserSignup.fs │ ├── Wall.fs │ ├── assets │ ├── css │ │ └── styles.css │ ├── images │ │ ├── FsTweetLogo.png │ │ └── favicon.ico │ └── js │ │ ├── lib │ │ └── getstream.js │ │ ├── profile.js │ │ ├── social.js │ │ ├── tweet.js │ │ └── wall.js │ ├── paket.references │ └── views │ ├── guest │ └── home.liquid │ ├── master_page.liquid │ ├── not_found.liquid │ ├── server_error.liquid │ └── user │ ├── login.liquid │ ├── profile.liquid │ ├── signup.liquid │ ├── signup_success.liquid │ ├── verification_success.liquid │ └── wall.liquid └── web.config /.deployment: -------------------------------------------------------------------------------- 1 | [config] 2 | command = build.cmd Deploy -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | *.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignorable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | orleans.codegen.cs 203 | 204 | # Since there are multiple workflows, uncomment next line to ignore bower_components 205 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 206 | #bower_components/ 207 | 208 | # RIA/Silverlight projects 209 | Generated_Code/ 210 | 211 | # Backup & report files from converting an old project file 212 | # to a newer Visual Studio version. Backup files are not needed, 213 | # because we have git ;-) 214 | _UpgradeReport_Files/ 215 | Backup*/ 216 | UpgradeLog*.XML 217 | UpgradeLog*.htm 218 | 219 | # SQL Server files 220 | *.mdf 221 | *.ldf 222 | *.ndf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | node_modules/ 238 | 239 | # Typescript v1 declaration files 240 | typings/ 241 | 242 | # Visual Studio 6 build log 243 | *.plg 244 | 245 | # Visual Studio 6 workspace options file 246 | *.opt 247 | 248 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 249 | *.vbw 250 | 251 | # Visual Studio LightSwitch build output 252 | **/*.HTMLClient/GeneratedArtifacts 253 | **/*.DesktopClient/GeneratedArtifacts 254 | **/*.DesktopClient/ModelManifest.xml 255 | **/*.Server/GeneratedArtifacts 256 | **/*.Server/ModelManifest.xml 257 | _Pvt_Extensions 258 | 259 | # Paket dependency manager 260 | paket-files/ 261 | 262 | # FAKE - F# Make 263 | .fake/ 264 | build 265 | 266 | # JetBrains Rider 267 | .idea/ 268 | *.sln.iml 269 | 270 | # CodeRush 271 | .cr/ 272 | 273 | # Python Tools for Visual Studio (PTVS) 274 | __pycache__/ 275 | *.pyc 276 | 277 | # Cake - Uncomment if you are using it 278 | # tools/** 279 | # !tools/packages.config 280 | 281 | # Telerik's JustMock configuration file 282 | *.jmconfig 283 | 284 | # BizTalk build output 285 | *.btp.cs 286 | *.btm.cs 287 | *.odx.cs 288 | *.xsd.cs 289 | 290 | .envrc 291 | bin -------------------------------------------------------------------------------- /.paket/Paket.Restore.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 8 | 9 | true 10 | $(MSBuildThisFileDirectory) 11 | $(MSBuildThisFileDirectory)..\ 12 | $(PaketRootPath)paket-files\paket.restore.cached 13 | $(PaketRootPath)paket.lock 14 | /Library/Frameworks/Mono.framework/Commands/mono 15 | mono 16 | 17 | $(PaketRootPath)paket.exe 18 | $(PaketToolsPath)paket.exe 19 | "$(PaketExePath)" 20 | $(MonoPath) --runtime=v4.0.30319 "$(PaketExePath)" 21 | $(PaketRootPath)paket.bootstrapper.exe 22 | $(PaketToolsPath)paket.bootstrapper.exe 23 | "$(PaketBootStrapperExePath)" 24 | $(MonoPath) --runtime=v4.0.30319 "$(PaketBootStrapperExePath)" 25 | 26 | 27 | 28 | 29 | true 30 | true 31 | 32 | 33 | 34 | 35 | 36 | 37 | true 38 | $(NoWarn);NU1603 39 | 40 | 41 | 42 | 43 | /usr/bin/shasum $(PaketRestoreCacheFile) | /usr/bin/awk '{ print $1 }' 44 | /usr/bin/shasum $(PaketLockFilePath) | /usr/bin/awk '{ print $1 }' 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | $([System.IO.File]::ReadAllText('$(PaketRestoreCacheFile)')) 58 | $([System.IO.File]::ReadAllText('$(PaketLockFilePath)')) 59 | true 60 | false 61 | true 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | $(MSBuildProjectDirectory)\obj\$(MSBuildProjectFile).paket.references.cached 71 | 72 | $(MSBuildProjectFullPath).paket.references 73 | 74 | $(MSBuildProjectDirectory)\$(MSBuildProjectName).paket.references 75 | 76 | $(MSBuildProjectDirectory)\paket.references 77 | $(MSBuildProjectDirectory)\obj\$(MSBuildProjectFile).$(TargetFramework).paket.resolved 78 | true 79 | references-file-or-cache-not-found 80 | 81 | 82 | 83 | 84 | $([System.IO.File]::ReadAllText('$(PaketReferencesCachedFilePath)')) 85 | $([System.IO.File]::ReadAllText('$(PaketOriginalReferencesFilePath)')) 86 | references-file 87 | false 88 | 89 | 90 | 91 | 92 | false 93 | 94 | 95 | 96 | 97 | true 98 | target-framework '$(TargetFramework)' 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | $([System.String]::Copy('%(PaketReferencesFileLines.Identity)').Split(',')[0]) 116 | $([System.String]::Copy('%(PaketReferencesFileLines.Identity)').Split(',')[1]) 117 | 118 | 119 | %(PaketReferencesFileLinesInfo.PackageVersion) 120 | 121 | 122 | 123 | 124 | $(MSBuildProjectDirectory)/obj/$(MSBuildProjectFile).paket.clitools 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | $([System.String]::Copy('%(PaketCliToolFileLines.Identity)').Split(',')[0]) 134 | $([System.String]::Copy('%(PaketCliToolFileLines.Identity)').Split(',')[1]) 135 | 136 | 137 | %(PaketCliToolFileLinesInfo.PackageVersion) 138 | 139 | 140 | 141 | 142 | $(MSBuildProjectDirectory)/obj/$(MSBuildProjectFile).NuGet.Config 143 | 144 | 145 | 146 | 147 | 148 | 149 | false 150 | 151 | 152 | 153 | 154 | 155 | <_NuspecFilesNewLocation Include="$(BaseIntermediateOutputPath)$(Configuration)\*.nuspec"/> 156 | 157 | 158 | 159 | $(MSBuildProjectDirectory)/$(MSBuildProjectFile) 160 | true 161 | false 162 | true 163 | $(BaseIntermediateOutputPath)$(Configuration) 164 | $(BaseIntermediateOutputPath) 165 | 166 | 167 | 168 | <_NuspecFiles Include="$(AdjustedNuspecOutputPath)\*.nuspec"/> 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 221 | 222 | 263 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /.paket/paket.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demystifyfp/FsTweet/a3fbd1431b829566c3179d7093eb53f0b99c7ac3/.paket/paket.exe -------------------------------------------------------------------------------- /.paket/paket.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | $(MSBuildThisFileDirectory) 8 | $(MSBuildThisFileDirectory)..\ 9 | /Library/Frameworks/Mono.framework/Commands/mono 10 | mono 11 | 12 | 13 | 14 | 15 | $(PaketRootPath)paket.exe 16 | $(PaketToolsPath)paket.exe 17 | "$(PaketExePath)" 18 | $(MonoPath) --runtime=v4.0.30319 "$(PaketExePath)" 19 | 20 | 21 | 22 | 23 | 24 | $(MSBuildProjectFullPath).paket.references 25 | 26 | 27 | 28 | 29 | $(MSBuildProjectDirectory)\$(MSBuildProjectName).paket.references 30 | 31 | 32 | 33 | 34 | $(MSBuildProjectDirectory)\paket.references 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | $(PaketCommand) restore --references-files "$(PaketReferences)" 47 | 48 | RestorePackages; $(BuildDependsOn); 49 | 50 | 51 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Forge.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | run='fake Run' 3 | web='-p src/FsTweet.Web/FsTweet.Web.fsproj' 4 | newFs='new file -t fs' 5 | moveUp='move file -u' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Demystify FP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: mono build/FsTweet.Web.exe -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FsTweet -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | cls 3 | 4 | .paket\paket.exe restore 5 | if errorlevel 1 ( 6 | exit /b %errorlevel% 7 | ) 8 | 9 | packages\FAKE\tools\FAKE.exe build.fsx %* 10 | -------------------------------------------------------------------------------- /build.fsx: -------------------------------------------------------------------------------- 1 | // include Fake libs 2 | #r "./packages/FAKE/tools/FakeLib.dll" 3 | #r "./packages/FAKE/tools/Fake.FluentMigrator.dll" 4 | #r "./packages/database/Npgsql/lib/net451/Npgsql.dll" 5 | 6 | open Fake 7 | open Fake.FluentMigratorHelper 8 | open System.IO 9 | open Fake.Azure 10 | 11 | 12 | let env = environVar "FSTWEET_ENVIRONMENT" 13 | 14 | // Directories 15 | let buildDir = 16 | if env = "dev" then 17 | "./build" 18 | else 19 | Kudu.deploymentTemp 20 | 21 | let migrationsAssembly = 22 | combinePaths buildDir "FsTweet.Db.Migrations.dll" 23 | 24 | // Targets 25 | Target "Clean" (fun _ -> 26 | CleanDirs [buildDir] 27 | ) 28 | 29 | Target "BuildMigrations" (fun _ -> 30 | !! "src/FsTweet.Db.Migrations/*.fsproj" 31 | |> MSBuildDebug buildDir "Build" 32 | |> Log "MigrationBuild-Output: " 33 | ) 34 | let localDbConnString = @"Server=127.0.0.1;Port=5432;Database=FsTweet;User Id=postgres;Password=test;" 35 | let connString = 36 | environVarOrDefault 37 | "FSTWEET_DB_CONN_STRING" 38 | localDbConnString 39 | 40 | setEnvironVar "FSTWEET_DB_CONN_STRING" connString 41 | let dbConnection = ConnectionString (connString, DatabaseProvider.PostgreSQL) 42 | 43 | Target "RunMigrations" (fun _ -> 44 | MigrateToLatest dbConnection [migrationsAssembly] DefaultMigrationOptions 45 | ) 46 | 47 | 48 | 49 | let buildConfig = 50 | if env = "dev" then MSBuildDebug else MSBuildRelease 51 | 52 | Target "Build" (fun _ -> 53 | !! "src/FsTweet.Web/*.fsproj" 54 | |> buildConfig buildDir "Build" 55 | |> Log "AppBuild-Output: " 56 | ) 57 | 58 | Target "Run" (fun _ -> 59 | ExecProcess 60 | (fun info -> info.FileName <- "./build/FsTweet.Web.exe") 61 | (System.TimeSpan.FromDays 1.) 62 | |> ignore 63 | ) 64 | 65 | let noFilter = fun _ -> true 66 | 67 | let copyToBuildDir srcDir targetDirName = 68 | let targetDir = combinePaths buildDir targetDirName 69 | CopyDir targetDir srcDir noFilter 70 | 71 | Target "Views" (fun _ -> 72 | copyToBuildDir "./src/FsTweet.Web/views" "views" 73 | ) 74 | 75 | Target "Assets" (fun _ -> 76 | copyToBuildDir "./src/FsTweet.Web/assets" "assets" 77 | ) 78 | 79 | let dbFilePath = "./src/FsTweet.Web/Db.fs" 80 | 81 | Target "VerifyLocalDbConnString" (fun _ -> 82 | let dbFileContent = File.ReadAllText dbFilePath 83 | if not (dbFileContent.Contains(localDbConnString)) then 84 | failwith "local db connection string mismatch" 85 | ) 86 | 87 | let swapDbFileContent (oldValue: string) (newValue : string) = 88 | let dbFileContent = File.ReadAllText dbFilePath 89 | let newDbFileContent = dbFileContent.Replace(oldValue, newValue) 90 | File.WriteAllText(dbFilePath, newDbFileContent) 91 | 92 | Target "ReplaceLocalDbConnStringForBuild" (fun _ -> 93 | swapDbFileContent localDbConnString connString 94 | ) 95 | Target "RevertLocalDbConnStringChange" (fun _ -> 96 | swapDbFileContent connString localDbConnString 97 | ) 98 | 99 | Target "CopyWebConfig" ( fun _ -> 100 | FileHelper.CopyFile Kudu.deploymentTemp "web.config") 101 | 102 | Target "Deploy" Kudu.kuduSync 103 | 104 | // Build order 105 | "Clean" 106 | ==> "BuildMigrations" 107 | ==> "RunMigrations" 108 | ==> "VerifyLocalDbConnString" 109 | ==> "ReplaceLocalDbConnStringForBuild" 110 | ==> "Build" 111 | ==> "RevertLocalDbConnStringChange" 112 | ==> "Views" 113 | ==> "Assets" 114 | 115 | 116 | "Assets" 117 | ==> "Run" 118 | 119 | "Assets" 120 | ==> "CopyWebConfig" 121 | ==> "Deploy" 122 | 123 | // start build 124 | RunTargetOrDefault "Assets" 125 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mono= 4 | fsiargs=() 5 | 6 | if [[ "$OS" != "Windows_NT" ]]; then 7 | mono=mono 8 | fsiargs=(--fsiargs -d:MONO) 9 | 10 | # http://fsharp.github.io/FAKE/watch.html 11 | export MONO_MANAGED_WATCHER=false 12 | fi 13 | 14 | $mono .paket/paket.exe restore || exit $? 15 | $mono packages/FAKE/tools/FAKE.exe "$@" "${fsiargs[@]}" build.fsx 16 | -------------------------------------------------------------------------------- /forge: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demystifyfp/FsTweet/a3fbd1431b829566c3179d7093eb53f0b99c7ac3/forge -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source https://www.nuget.org/api/v2 2 | framework: net461 3 | nuget BCrypt.Net-Next 4 | nuget Chessie 5 | nuget Chiron 6 | nuget DotLiquid 2.0.64 7 | nuget FAKE 8 | nuget FSharp.Core 9 | nuget Logary 10 | nuget NodaTime 1.3.2 11 | nuget stream-net 12 | nuget Suave 13 | nuget Suave.DotLiquid 14 | nuget Suave.Experimental 15 | 16 | group Database 17 | source https://www.nuget.org/api/v2 18 | framework: net461 19 | 20 | nuget FluentMigrator 21 | nuget Npgsql 3.1.10 22 | nuget SQLProvider 23 | 24 | group Email 25 | source https://www.nuget.org/api/v2 26 | framework: net461 27 | 28 | nuget Postmark -------------------------------------------------------------------------------- /paket.lock: -------------------------------------------------------------------------------- 1 | RESTRICTION: == net461 2 | NUGET 3 | remote: https://www.nuget.org/api/v2 4 | Aether (8.2) 5 | FSharp.Core (>= 4.1) 6 | BCrypt.Net-Next (2.1.1) 7 | System.Text.RegularExpressions (>= 4.3) 8 | Chessie (0.6) 9 | FSharp.Core 10 | Chiron (6.2.1) 11 | Aether (>= 8.0.2 < 9.0) 12 | FParsec (>= 1.0 < 2.0) 13 | DotLiquid (2.0.64) 14 | FAKE (4.63) 15 | FParsec (1.0.3) 16 | FSharp.Core (>= 4.0.0.1) 17 | FSharp.Core (4.2.3) 18 | Hopac (0.3.23) 19 | FSharp.Core (>= 3.1.2.5) 20 | Logary (4.2.1) 21 | FParsec (>= 1.0.2) 22 | FSharp.Core (>= 4.0.0.1) 23 | Hopac (>= 0.3.23) 24 | NodaTime (>= 1.3.2) 25 | Newtonsoft.Json (10.0.3) 26 | NodaTime (1.3.2) 27 | RestSharp (105.2.3) 28 | stream-net (1.3.2) 29 | Newtonsoft.Json (>= 6.0.8) 30 | RestSharp (>= 105.0.1) 31 | Suave (2.2.1) 32 | FSharp.Core (>= 4.0.0.1) 33 | Suave.DotLiquid (2.2) 34 | DotLiquid (>= 2.0.64) 35 | FSharp.Core (>= 4.0.0.1) 36 | Suave (>= 2.2) 37 | Suave.Experimental (2.2) 38 | FSharp.Core (>= 4.0.0.1) 39 | Suave (>= 2.2) 40 | System.Text.RegularExpressions (4.3) 41 | 42 | GROUP Database 43 | RESTRICTION: == net461 44 | NUGET 45 | remote: https://www.nuget.org/api/v2 46 | FluentMigrator (1.6.2) 47 | Npgsql (3.1.10) 48 | SQLProvider (1.1.7) 49 | 50 | GROUP Email 51 | RESTRICTION: == net461 52 | NUGET 53 | remote: https://www.nuget.org/api/v2 54 | Newtonsoft.Json (10.0.3) 55 | Postmark (2.2.1) 56 | Newtonsoft.Json (>= 6.0.6) 57 | -------------------------------------------------------------------------------- /script.fsx: -------------------------------------------------------------------------------- 1 | #r "./packages/Suave/lib/net40/Suave.dll" 2 | 3 | open Suave.Utils 4 | open System 5 | 6 | Crypto.generateKey Crypto.KeyLength 7 | |> Convert.ToBase64String 8 | |> printfn "%s" -------------------------------------------------------------------------------- /src/FsTweet.Db.Migrations/FsTweet.Db.Migrations.fs: -------------------------------------------------------------------------------- 1 | namespace FsTweet.Db.Migrations 2 | 3 | open FluentMigrator 4 | 5 | [] 6 | type CreateUserTable()= 7 | inherit Migration() 8 | 9 | override this.Up() = 10 | base.Create.Table("Users") 11 | .WithColumn("Id").AsInt32().PrimaryKey().Identity() 12 | .WithColumn("Username").AsString(12).Unique().NotNullable() 13 | .WithColumn("Email").AsString(254).Unique().NotNullable() 14 | .WithColumn("PasswordHash").AsString().NotNullable() 15 | .WithColumn("EmailVerificationCode").AsString().NotNullable() 16 | .WithColumn("IsEmailVerified").AsBoolean() 17 | |> ignore 18 | 19 | override this.Down() = 20 | base.Delete.Table("Users") |> ignore 21 | 22 | [] 23 | type CreateTweetTable()= 24 | inherit Migration() 25 | 26 | override this.Up() = 27 | base.Create.Table("Tweets") 28 | .WithColumn("Id").AsGuid().PrimaryKey() 29 | .WithColumn("Post").AsString(144).NotNullable() 30 | .WithColumn("UserId").AsInt32().ForeignKey("Users", "Id") 31 | .WithColumn("TweetedAt").AsDateTimeOffset().NotNullable() 32 | |> ignore 33 | 34 | override this.Down() = 35 | base.Delete.Table("Tweets") |> ignore 36 | 37 | [] 38 | type CreateSocialTable()= 39 | inherit Migration() 40 | 41 | override this.Up() = 42 | base.Create.Table("Social") 43 | .WithColumn("Id").AsGuid().PrimaryKey().Identity() 44 | .WithColumn("FollowerUserId").AsInt32().ForeignKey("Users", "Id").NotNullable() 45 | .WithColumn("FollowingUserId").AsInt32().ForeignKey("Users", "Id").NotNullable() 46 | |> ignore 47 | base.Create.UniqueConstraint("SocialRelationship") 48 | .OnTable("Social") 49 | .Columns("FollowerUserId", "FollowingUserId") |> ignore 50 | 51 | override this.Down() = 52 | base.Delete.Table("Tweets") |> ignore -------------------------------------------------------------------------------- /src/FsTweet.Db.Migrations/FsTweet.Db.Migrations.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | 2.0 8 | 88812978-2cec-4408-be04-8b11ad27c347 9 | Library 10 | FsTweet.Db.Migrations 11 | FsTweet.Db.Migrations 12 | v4.6.1 13 | true 14 | 4.4.0.0 15 | FsTweet.Db.Migrations 16 | 17 | 18 | true 19 | full 20 | false 21 | false 22 | bin\$(Configuration)\ 23 | DEBUG;TRACE 24 | 3 25 | bin\$(Configuration)\$(AssemblyName).XML 26 | 27 | 28 | pdbonly 29 | true 30 | true 31 | bin\$(Configuration)\ 32 | TRACE 33 | 3 34 | bin\$(Configuration)\$(AssemblyName).XML 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 14 48 | 49 | 50 | 51 | 52 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets 53 | 54 | 55 | 56 | 57 | $(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\4.0\Framework\v4.0\Microsoft.FSharp.Targets 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | 73 | ..\..\packages\database\FluentMigrator\lib\40\FluentMigrator.dll 74 | True 75 | True 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ..\..\packages\FSharp.Core\lib\net45\FSharp.Core.dll 85 | True 86 | True 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/FsTweet.Db.Migrations/Script.fsx: -------------------------------------------------------------------------------- 1 | #load "FsTweet.Db.Migrations.fs" 2 | open FsTweet.Db.Migrations 3 | 4 | // Define your library scripting code here 5 | -------------------------------------------------------------------------------- /src/FsTweet.Db.Migrations/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | group Database 3 | FluentMigrator -------------------------------------------------------------------------------- /src/FsTweet.Web/Auth.fs: -------------------------------------------------------------------------------- 1 | namespace Auth 2 | 3 | module Domain = 4 | open User 5 | open Chessie.ErrorHandling 6 | open Chessie 7 | 8 | type LoginRequest = { 9 | Username : Username 10 | Password : Password 11 | } 12 | with static member TryCreate (username, password) = 13 | trial { 14 | let! username = Username.TryCreate username 15 | let! password = Password.TryCreate password 16 | return { 17 | Username = username 18 | Password = password 19 | } 20 | } 21 | 22 | type LoginError = 23 | | UsernameNotFound 24 | | EmailNotVerified 25 | | PasswordMisMatch 26 | | Error of System.Exception 27 | 28 | type Login = FindUser -> LoginRequest -> AsyncResult 29 | 30 | let login (findUser : FindUser) (req : LoginRequest) = asyncTrial { 31 | let! userToFind = 32 | findUser req.Username |> AR.mapFailure Error 33 | match userToFind with 34 | | None -> 35 | return! AR.fail UsernameNotFound 36 | | Some user -> 37 | match user.EmailAddress with 38 | | NotVerified _ -> 39 | return! AR.fail EmailNotVerified 40 | | Verified _ -> 41 | let isMatchingPassword = 42 | PasswordHash.VerifyPassword req.Password user.PasswordHash 43 | match isMatchingPassword with 44 | | false -> return! AR.fail PasswordMisMatch 45 | | _ -> return user 46 | } 47 | 48 | module Suave = 49 | open Suave 50 | open Suave.Filters 51 | open Suave.Operators 52 | open Suave.DotLiquid 53 | open Suave.Form 54 | open Domain 55 | open Chessie.ErrorHandling 56 | open Chessie 57 | open User 58 | open Suave.Authentication 59 | open Suave.Cookie 60 | open Suave.State.CookieStateStore 61 | 62 | type LoginViewModel = { 63 | Username : string 64 | Password : string 65 | Error : string option 66 | } 67 | 68 | let emptyLoginViewModel = { 69 | Username = "" 70 | Password = "" 71 | Error = None 72 | } 73 | 74 | let loginTemplatePath = "user/login.liquid" 75 | 76 | let redirectToWallPage = 77 | Redirection.FOUND "/wall" 78 | 79 | let renderLoginPage (viewModel : LoginViewModel) hasUserLoggedIn = 80 | match hasUserLoggedIn with 81 | | Some _ -> redirectToWallPage 82 | | _ -> page loginTemplatePath viewModel 83 | 84 | 85 | let userSessionKey = "fsTweetUser" 86 | 87 | let setState key value ctx = 88 | match HttpContext.state ctx with 89 | | Some state -> 90 | state.set key value 91 | | _ -> never 92 | 93 | let createUserSession (user : User) = 94 | statefulForSession 95 | >=> context (setState userSessionKey user) 96 | 97 | let retrieveUser ctx : User option = 98 | match HttpContext.state ctx with 99 | | Some state -> 100 | state.get userSessionKey 101 | | _ -> None 102 | let initUserSession fFailure fSuccess ctx = 103 | match retrieveUser ctx with 104 | | Some user -> fSuccess user 105 | | _ -> fFailure 106 | let userSession fFailure fSuccess = 107 | statefulForSession 108 | >=> context (initUserSession fFailure fSuccess) 109 | 110 | let optionalUserSession fSuccess = 111 | statefulForSession 112 | >=> context (fun ctx -> fSuccess (retrieveUser ctx)) 113 | 114 | let redirectToLoginPage = 115 | Redirection.FOUND "/login" 116 | 117 | let onAuthenticate fSuccess fFailure = 118 | authenticate CookieLife.Session false 119 | (fun _ -> Choice2Of2 fFailure) 120 | (fun _ -> Choice2Of2 fFailure) 121 | (userSession fFailure fSuccess) 122 | 123 | let requiresAuth fSuccess = 124 | onAuthenticate fSuccess redirectToLoginPage 125 | 126 | let requiresAuth2 fSuccess = 127 | onAuthenticate fSuccess JSON.unauthorized 128 | 129 | let mayRequiresAuth fSuccess = 130 | authenticate CookieLife.Session false 131 | (fun _ -> Choice2Of2 (fSuccess None)) 132 | (fun _ -> Choice2Of2 (fSuccess None)) 133 | (optionalUserSession fSuccess) 134 | 135 | let onLoginSuccess viewModel (user : User) = 136 | authenticated CookieLife.Session false 137 | >=> createUserSession user 138 | >=> redirectToWallPage 139 | 140 | let onLoginFailure viewModel loginError = 141 | match loginError with 142 | | PasswordMisMatch -> 143 | let vm = 144 | {viewModel with Error = Some "password didn't match"} 145 | renderLoginPage vm None 146 | | EmailNotVerified -> 147 | let vm = 148 | {viewModel with Error = Some "email not verified"} 149 | renderLoginPage vm None 150 | | UsernameNotFound -> 151 | let vm = 152 | {viewModel with Error = Some "invalid username"} 153 | renderLoginPage vm None 154 | | Error ex -> 155 | printfn "%A" ex 156 | let vm = 157 | {viewModel with Error = Some "something went wrong"} 158 | renderLoginPage vm None 159 | 160 | let handleUserLogin findUser (ctx : HttpContext) = async { 161 | match bindEmptyForm ctx.request with 162 | | Choice1Of2 (vm : LoginViewModel) -> 163 | let result = 164 | LoginRequest.TryCreate (vm.Username, vm.Password) 165 | match result with 166 | | Success req -> 167 | let! webpart = 168 | login findUser req 169 | |> AR.either (onLoginSuccess vm) (onLoginFailure vm) 170 | return! webpart ctx 171 | | Failure err -> 172 | let viewModel = {vm with Error = Some err} 173 | return! renderLoginPage viewModel None ctx 174 | | Choice2Of2 err -> 175 | let viewModel = 176 | {emptyLoginViewModel with Error = Some err} 177 | return! renderLoginPage viewModel None ctx 178 | } 179 | 180 | let webpart getDataCtx = 181 | let findUser = Persistence.findUser getDataCtx 182 | choose [ 183 | path "/login" >=> choose [ 184 | GET >=> mayRequiresAuth (renderLoginPage emptyLoginViewModel) 185 | POST >=> handleUserLogin findUser 186 | ] 187 | path "/logout" >=> deauthenticate >=> redirectToLoginPage 188 | ] -------------------------------------------------------------------------------- /src/FsTweet.Web/Chessie.fs: -------------------------------------------------------------------------------- 1 | module Chessie 2 | 3 | open Chessie.ErrorHandling 4 | 5 | let mapFailure f result = 6 | let mapFirstItem xs = 7 | List.head xs |> f |> List.singleton 8 | mapFailure mapFirstItem result 9 | 10 | let onSuccess f (x, _) = f x 11 | 12 | let onFailure f xs = 13 | xs |> List.head |> f 14 | 15 | let either onSuccessF onFailureF = 16 | either (onSuccess onSuccessF) (onFailure onFailureF) 17 | 18 | let (|Success|Failure|) result = 19 | match result with 20 | | Ok (x,_) -> Success x 21 | | Bad errs -> Failure (List.head errs) 22 | 23 | [] 24 | module AR = 25 | 26 | let mapFailure f aResult = 27 | aResult 28 | |> Async.ofAsyncResult 29 | |> Async.map (mapFailure f) |> AR 30 | 31 | let catch aComputation = 32 | aComputation 33 | |> Async.Catch 34 | |> Async.map ofChoice 35 | |> AR 36 | 37 | let fail x = 38 | x 39 | |> fail 40 | |> Async.singleton 41 | |> AR 42 | 43 | let either onSuccess onFailure aResult = 44 | aResult 45 | |> Async.ofAsyncResult 46 | |> Async.map (either onSuccess onFailure) -------------------------------------------------------------------------------- /src/FsTweet.Web/Db.fs: -------------------------------------------------------------------------------- 1 | module Database 2 | 3 | open FSharp.Data.Sql 4 | open Chessie.ErrorHandling 5 | open System 6 | open Npgsql 7 | open Chessie 8 | 9 | [] 10 | let private connString = 11 | "Server=127.0.0.1;Port=5432;Database=FsTweet;User Id=postgres;Password=test;" 12 | 13 | [] 14 | let private npgsqlLibPath = @"./../../packages/database/Npgsql/lib/net451" 15 | 16 | [] 17 | let private dbVendor = Common.DatabaseProviderTypes.POSTGRESQL 18 | 19 | type Db = SqlDataProvider< 20 | ConnectionString=connString, 21 | DatabaseVendor=dbVendor, 22 | ResolutionPath=npgsqlLibPath, 23 | UseOptionTypes=true> 24 | 25 | type DataContext = Db.dataContext 26 | 27 | type GetDataContext = unit -> DataContext 28 | let dataContext (connString : string) : GetDataContext = 29 | let isMono = 30 | System.Type.GetType ("Mono.Runtime") <> null 31 | match isMono with 32 | | true -> 33 | // SQLProvider doesn't support async transaction in mono 34 | let opts : Transactions.TransactionOptions = { 35 | IsolationLevel = Transactions.IsolationLevel.DontCreateTransaction 36 | Timeout = System.TimeSpan.MaxValue 37 | } 38 | fun _ -> Db.GetDataContext(connString, opts) 39 | | _ -> 40 | fun _ -> Db.GetDataContext connString 41 | 42 | 43 | let submitUpdates (ctx: DataContext) = 44 | ctx.SubmitUpdatesAsync() 45 | |> AR.catch 46 | 47 | let (|UniqueViolation|_|) constraintName (ex : Exception) = 48 | match ex with 49 | | :? AggregateException as agEx -> 50 | match agEx.Flatten().InnerException with 51 | | :? PostgresException as pgEx -> 52 | if pgEx.ConstraintName = constraintName && 53 | pgEx.SqlState = "23505" then 54 | Some () 55 | else None 56 | | _ -> None 57 | | _ -> None 58 | -------------------------------------------------------------------------------- /src/FsTweet.Web/Email.fs: -------------------------------------------------------------------------------- 1 | module Email 2 | 3 | open PostmarkDotNet 4 | open Chessie.ErrorHandling 5 | open System 6 | 7 | type Email = { 8 | To : string 9 | TemplateId : int64 10 | PlaceHolders : Map 11 | } 12 | 13 | type SendEmail = Email -> AsyncResult 14 | 15 | let mapPostmarkResponse response = 16 | match response with 17 | | Choice1Of2 ( postmarkRes : PostmarkResponse) -> 18 | match postmarkRes.Status with 19 | | PostmarkStatus.Success -> 20 | ok () 21 | | _ -> 22 | let ex = new Exception(postmarkRes.Message) 23 | fail ex 24 | | Choice2Of2 ex -> fail ex 25 | 26 | let sendEmailViaPostmark senderEmailAddress (client : PostmarkClient) email = 27 | let msg = 28 | new TemplatedPostmarkMessage( 29 | From = senderEmailAddress, 30 | To = email.To, 31 | TemplateId = email.TemplateId, 32 | TemplateModel = email.PlaceHolders 33 | ) 34 | client.SendMessageAsync(msg) 35 | |> Async.AwaitTask 36 | |> Async.Catch 37 | |> Async.map mapPostmarkResponse 38 | |> AR 39 | 40 | let initSendEmail senderEmailAddress serverToken : SendEmail = 41 | let client = new PostmarkClient(serverToken) 42 | sendEmailViaPostmark senderEmailAddress client 43 | 44 | let consoleSendEmail email = asyncTrial { 45 | printfn "%A" email 46 | } -------------------------------------------------------------------------------- /src/FsTweet.Web/FsTweet.Web.fs: -------------------------------------------------------------------------------- 1 | module FsTweetWeb.Main 2 | 3 | open Suave 4 | open Suave.Filters 5 | open Suave.Operators 6 | open Suave.DotLiquid 7 | open System.IO 8 | open System.Reflection 9 | open Suave.Files 10 | open Database 11 | open System 12 | open Email 13 | open System.Net 14 | open Logary.Configuration 15 | open Logary 16 | open Logary.Targets 17 | open Hopac 18 | 19 | 20 | let currentPath = 21 | Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 22 | 23 | let initDotLiquid () = 24 | let templatesDir = Path.Combine(currentPath, "views") 25 | setTemplatesDir templatesDir 26 | 27 | let serveAssets = 28 | let faviconPath = 29 | Path.Combine(currentPath, "assets", "images", "favicon.ico") 30 | choose [ 31 | pathRegex "/assets/*" >=> browseHome 32 | path "/favicon.ico" >=> file faviconPath 33 | ] 34 | 35 | let readUserState ctx key : 'value option = 36 | ctx.userState 37 | |> Map.tryFind key 38 | |> Option.map (fun x -> x :?> 'value) 39 | let logIfError (logger : Logger) ctx = 40 | readUserState ctx "err" 41 | |> Option.iter logger.logSimple 42 | succeed 43 | 44 | [] 45 | let main argv = 46 | initDotLiquid () 47 | setCSharpNamingConvention () 48 | 49 | let fsTweetConnString = 50 | Environment.GetEnvironmentVariable "FSTWEET_DB_CONN_STRING" 51 | 52 | let serverToken = 53 | Environment.GetEnvironmentVariable "FSTWEET_POSTMARK_SERVER_TOKEN" 54 | 55 | let senderEmailAddress = 56 | Environment.GetEnvironmentVariable "FSTWEET_SENDER_EMAIL_ADDRESS" 57 | 58 | let env = 59 | Environment.GetEnvironmentVariable "FSTWEET_ENVIRONMENT" 60 | 61 | let streamConfig : GetStream.Config = { 62 | ApiKey = 63 | Environment.GetEnvironmentVariable "FSTWEET_STREAM_KEY" 64 | ApiSecret = 65 | Environment.GetEnvironmentVariable "FSTWEET_STREAM_SECRET" 66 | AppId = 67 | Environment.GetEnvironmentVariable "FSTWEET_STREAM_APP_ID" 68 | } 69 | 70 | let sendEmail = 71 | match env with 72 | | "dev" -> consoleSendEmail 73 | | _ -> initSendEmail senderEmailAddress serverToken 74 | 75 | let getDataCtx = dataContext fsTweetConnString 76 | 77 | let getStreamClient = GetStream.newClient streamConfig 78 | 79 | let app = 80 | choose [ 81 | serveAssets 82 | path "/" >=> page "guest/home.liquid" "" 83 | UserSignup.Suave.webPart getDataCtx sendEmail 84 | Auth.Suave.webpart getDataCtx 85 | Wall.Suave.webpart getDataCtx getStreamClient 86 | Social.Suave.webpart getDataCtx getStreamClient 87 | UserProfile.Suave.webpart getDataCtx getStreamClient 88 | ] 89 | 90 | let serverKey = 91 | Environment.GetEnvironmentVariable "FSTWEET_SERVER_KEY" 92 | |> ServerKey.fromBase64 93 | 94 | let ipZero = IPAddress.Parse("0.0.0.0") 95 | 96 | let port = 97 | Environment.GetEnvironmentVariable "PORT" 98 | 99 | let targets = withTarget (Console.create Console.empty "console") 100 | let rules = withRule (Rule.createForTarget "console") 101 | let logaryConf = targets >> rules 102 | 103 | use logary = 104 | withLogaryManager "FsTweet.Web" logaryConf |> run 105 | 106 | let logger = 107 | logary.getLogger (PointName [|"Suave"|]) 108 | 109 | let serverConfig = 110 | {defaultConfig with 111 | serverKey = serverKey 112 | bindings=[HttpBinding.create HTTP ipZero (uint16 port)]} 113 | 114 | let appWithLogger = 115 | app >=> context (logIfError logger) 116 | startWebServer serverConfig appWithLogger 117 | 118 | 0 119 | -------------------------------------------------------------------------------- /src/FsTweet.Web/FsTweet.Web.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FsTweet.Web 5 | FsTweet.Web 6 | FsTweet.Web 7 | Debug 8 | AnyCPU 9 | 2.0 10 | 426ffd79-a5da-415b-b18f-354fabd289ae 11 | Exe 12 | v4.6.1 13 | true 14 | 4.3.1.0 15 | 16 | 17 | true 18 | Full 19 | false 20 | false 21 | bin\$(Configuration)\ 22 | DEBUG;TRACE 23 | 3 24 | $(Platform) 25 | 26 | 27 | PdbOnly 28 | true 29 | true 30 | bin\$(Configuration)\ 31 | TRACE 32 | 3 33 | $(Platform) 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets 62 | 63 | 64 | 65 | 66 | $(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\4.0\Framework\v4.0\Microsoft.FSharp.Targets 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | ..\..\packages\database\Npgsql\lib\net451\Npgsql.dll 76 | True 77 | True 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ..\..\packages\database\SQLProvider\lib\FSharp.Data.SqlProvider.dll 87 | True 88 | True 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ..\..\packages\email\Newtonsoft.Json\lib\net45\Newtonsoft.Json.dll 98 | True 99 | True 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | ..\..\packages\email\Postmark\lib\net45\Postmark.dll 109 | True 110 | True 111 | 112 | 113 | ..\..\packages\email\Postmark\lib\net45\Postmark.Convenience.dll 114 | True 115 | True 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ..\..\packages\Aether\lib\net45\Aether.dll 125 | True 126 | True 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | ..\..\packages\BCrypt.Net-Next\lib\net452\BCrypt.Net-Next.dll 136 | True 137 | True 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | ..\..\packages\Chessie\lib\net40\Chessie.dll 147 | True 148 | True 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | ..\..\packages\Chiron\lib\net40\Chiron.dll 158 | True 159 | True 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | ..\..\packages\DotLiquid\lib\net451\DotLiquid.dll 169 | True 170 | True 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | ..\..\packages\FParsec\lib\net40-client\FParsec.dll 180 | True 181 | True 182 | 183 | 184 | ..\..\packages\FParsec\lib\net40-client\FParsecCS.dll 185 | True 186 | True 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | ..\..\packages\FSharp.Core\lib\net45\FSharp.Core.dll 196 | True 197 | True 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | ..\..\packages\Hopac\lib\net45\Hopac.dll 207 | True 208 | True 209 | 210 | 211 | ..\..\packages\Hopac\lib\net45\Hopac.Core.dll 212 | True 213 | True 214 | 215 | 216 | ..\..\packages\Hopac\lib\net45\Hopac.Platform.dll 217 | True 218 | True 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | ..\..\packages\Logary\lib\net452\Logary.dll 228 | True 229 | True 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | True 239 | 240 | 241 | ..\..\packages\NodaTime\lib\net35-Client\NodaTime.dll 242 | True 243 | True 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | ..\..\packages\RestSharp\lib\net46\RestSharp.dll 253 | True 254 | True 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | ..\..\packages\stream-net\lib\net45\StreamNet.dll 264 | True 265 | True 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | ..\..\packages\Suave\lib\net40\Suave.dll 275 | True 276 | True 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | ..\..\packages\Suave.DotLiquid\lib\net40\Suave.DotLiquid.dll 286 | True 287 | True 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | ..\..\packages\Suave.Experimental\lib\net40\Suave.Experimental.dll 297 | True 298 | True 299 | 300 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /src/FsTweet.Web/Json.fs: -------------------------------------------------------------------------------- 1 | 2 | [] 3 | module JSON 4 | 5 | open Suave 6 | open Suave.Operators 7 | open System.Text 8 | open Chiron 9 | open Chessie.ErrorHandling 10 | 11 | let parse req = 12 | req.rawForm 13 | |> Encoding.UTF8.GetString 14 | |> Json.tryParse 15 | |> ofChoice 16 | 17 | // Note: Compiles only with F# 4.1 or above 18 | // let inline deserialize< ^a when (^a or FromJsonDefaults) 19 | // : (static member FromJson: ^a -> ^a Json)> 20 | // req : Result< ^a, string> = 21 | // parse req 22 | // |> bind (fun json -> 23 | // json 24 | // |> Json.tryDeserialize 25 | // |> ofChoice) 26 | 27 | // For F# 4.0 and below 28 | (* If you are using F# 4.1 or above, delete the this function and uncomment the above function*) 29 | let deserialize f req = 30 | parse req 31 | |> bind (fun json -> f json |> ofChoice) 32 | 33 | let contentType = "application/json; charset=utf-8" 34 | 35 | let json fWebpart json = 36 | json 37 | |> Json.format 38 | |> fWebpart 39 | >=> Writers.addHeader "Content-type" contentType 40 | 41 | let error fWebpart msg = 42 | ["msg", String msg] 43 | |> Map.ofList 44 | |> Object 45 | |> json fWebpart 46 | 47 | let badRequest msg = 48 | error RequestErrors.BAD_REQUEST msg 49 | let unauthorized = 50 | error RequestErrors.UNAUTHORIZED "login required" 51 | 52 | let internalError = 53 | error ServerErrors.INTERNAL_ERROR "something went wrong" 54 | 55 | let ok = 56 | json (Successful.OK) -------------------------------------------------------------------------------- /src/FsTweet.Web/Social.fs: -------------------------------------------------------------------------------- 1 | namespace Social 2 | 3 | module Domain = 4 | open System 5 | open Chessie.ErrorHandling 6 | open User 7 | 8 | type CreateFollowing = User -> UserId -> AsyncResult 9 | type Subscribe = User -> UserId -> AsyncResult 10 | type FollowUser = User -> UserId -> AsyncResult 11 | 12 | let followUser 13 | (subscribe : Subscribe) (createFollowing : CreateFollowing) 14 | user userId = asyncTrial { 15 | 16 | do! subscribe user userId 17 | do! createFollowing user userId 18 | 19 | } 20 | 21 | type IsFollowing = User -> UserId -> AsyncResult 22 | type FindFollowers = UserId -> AsyncResult 23 | type FindFollowingUsers = UserId -> AsyncResult 24 | 25 | module Persistence = 26 | open Database 27 | open User 28 | open Chessie.ErrorHandling 29 | open FSharp.Data.Sql 30 | open Chessie 31 | open User.Persistence 32 | open System.Linq 33 | let createFollowing (getDataCtx : GetDataContext) (user : User) (UserId userId) = 34 | 35 | let ctx = getDataCtx () 36 | let social = ctx.Public.Social.Create() 37 | let (UserId followerUserId) = user.UserId 38 | 39 | social.FollowerUserId <- followerUserId 40 | social.FollowingUserId <- userId 41 | 42 | submitUpdates ctx 43 | 44 | let isFollowing (getDataCtx : GetDataContext) (user : User) (UserId userId) = asyncTrial { 45 | let ctx = getDataCtx () 46 | let (UserId followerUserId) = user.UserId 47 | 48 | let! relationship = 49 | query { 50 | for s in ctx.Public.Social do 51 | where (s.FollowerUserId = followerUserId && 52 | s.FollowingUserId = userId) 53 | } |> Seq.tryHeadAsync |> AR.catch 54 | 55 | return relationship.IsSome 56 | } 57 | 58 | let findFollowers (getDataCtx : GetDataContext) (UserId userId) = asyncTrial { 59 | let ctx = getDataCtx() 60 | 61 | let selectFollowersQuery = query { 62 | for s in ctx.Public.Social do 63 | where (s.FollowingUserId = userId) 64 | select s.FollowerUserId 65 | } 66 | 67 | let! followers = 68 | query { 69 | for u in ctx.Public.Users do 70 | where (selectFollowersQuery.Contains(u.Id)) 71 | select u 72 | } |> Seq.executeQueryAsync |> AR.catch 73 | 74 | return! mapUserEntities followers 75 | } 76 | 77 | let findFollowingUsers (getDataCtx : GetDataContext) (UserId userId) = asyncTrial { 78 | let ctx = getDataCtx() 79 | 80 | let selectFollowingUsersQuery = query { 81 | for s in ctx.Public.Social do 82 | where (s.FollowerUserId = userId) 83 | select s.FollowingUserId 84 | } 85 | 86 | let! followingUsers = 87 | query { 88 | for u in ctx.Public.Users do 89 | where (selectFollowingUsersQuery.Contains(u.Id)) 90 | select u 91 | } |> Seq.executeQueryAsync |> AR.catch 92 | 93 | return! mapUserEntities followingUsers 94 | } 95 | 96 | module GetStream = 97 | open User 98 | open Chessie 99 | let subscribe (getStreamClient : GetStream.Client) (user : User) (UserId userId) = 100 | let (UserId followerUserId) = user.UserId 101 | let timelineFeed = 102 | GetStream.timeLineFeed getStreamClient followerUserId 103 | let userFeed = 104 | GetStream.userFeed getStreamClient userId 105 | timelineFeed.FollowFeed(userFeed) 106 | |> Async.AwaitTask 107 | |> AR.catch 108 | 109 | 110 | module Suave = 111 | open Suave 112 | open Suave.Filters 113 | open Suave.Operators 114 | open Auth.Suave 115 | open User 116 | open Chiron 117 | open Chessie 118 | open Persistence 119 | open Domain 120 | 121 | type FollowUserRequest = FollowUserRequest of int with 122 | static member FromJson (_ : FollowUserRequest) = json { 123 | let! userId = Json.read "userId" 124 | return FollowUserRequest userId 125 | } 126 | 127 | 128 | type UserDto = { 129 | Username : string 130 | } with 131 | static member ToJson (u:UserDto) = 132 | json { 133 | do! Json.write "username" u.Username 134 | } 135 | 136 | type UserDtoList = UserDtoList of (UserDto list) with 137 | static member ToJson (UserDtoList userDtos) = 138 | let usersJson = 139 | userDtos 140 | |> List.map (Json.serializeWith UserDto.ToJson) 141 | json { 142 | do! Json.write "users" usersJson 143 | } 144 | let mapUsersToUserDtoList (users : User list) = 145 | users 146 | |> List.map (fun user -> {Username = user.Username.Value}) 147 | |> UserDtoList 148 | 149 | 150 | let onFollowUserSuccess () = 151 | Successful.NO_CONTENT 152 | let onFollowUserFailure (ex : System.Exception) = 153 | printfn "%A" ex 154 | JSON.internalError 155 | 156 | let handleFollowUser (followUser : FollowUser) (user : User) ctx = async { 157 | match JSON.deserialize Json.tryDeserialize ctx.request with 158 | | Success (FollowUserRequest userId) -> 159 | let! webpart = 160 | followUser user (UserId userId) 161 | |> AR.either onFollowUserSuccess onFollowUserFailure 162 | return! webpart ctx 163 | | Failure _ -> 164 | return! JSON.badRequest "invalid user follow request" ctx 165 | } 166 | 167 | let onFindUsersFailure (ex : System.Exception) = 168 | printfn "%A" ex 169 | JSON.internalError 170 | 171 | let onFindUsersSuccess (users : User list) = 172 | mapUsersToUserDtoList users 173 | |> Json.serialize 174 | |> JSON.ok 175 | 176 | let fetchFollowers (findFollowers: FindFollowers) userId ctx = async { 177 | let! webpart = 178 | findFollowers (UserId userId) 179 | |> AR.either onFindUsersSuccess onFindUsersFailure 180 | return! webpart ctx 181 | } 182 | let fetchFollowingUsers (findFollowingUsers: FindFollowingUsers) userId ctx = async { 183 | let! webpart = 184 | findFollowingUsers (UserId userId) 185 | |> AR.either onFindUsersSuccess onFindUsersFailure 186 | return! webpart ctx 187 | } 188 | 189 | let webpart getDataCtx getStreamClient = 190 | let createFollowing = createFollowing getDataCtx 191 | let subscribe = GetStream.subscribe getStreamClient 192 | let followUser = followUser subscribe createFollowing 193 | let handleFollowUser = handleFollowUser followUser 194 | let findFollowers = findFollowers getDataCtx 195 | let findFollowingUsers = findFollowingUsers getDataCtx 196 | choose [ 197 | GET >=> pathScan "/%d/followers" (fetchFollowers findFollowers) 198 | GET >=> pathScan "/%d/following" (fetchFollowingUsers findFollowingUsers) 199 | POST >=> path "/follow" >=> requiresAuth2 handleFollowUser 200 | ] -------------------------------------------------------------------------------- /src/FsTweet.Web/Stream.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module GetStream 3 | open Chessie.ErrorHandling 4 | open Stream 5 | 6 | type Config = { 7 | ApiSecret : string 8 | ApiKey : string 9 | AppId : string 10 | } 11 | 12 | type Client = { 13 | Config : Config 14 | StreamClient : StreamClient 15 | } 16 | 17 | let newClient config = { 18 | StreamClient = 19 | new StreamClient(config.ApiKey, config.ApiSecret) 20 | Config = config 21 | } 22 | 23 | let userFeed getStreamClient (userId : int) = 24 | getStreamClient.StreamClient.Feed("user", userId.ToString()) 25 | 26 | let timeLineFeed getStreamClient (userId : int) = 27 | getStreamClient.StreamClient.Feed("timeline", userId.ToString()) -------------------------------------------------------------------------------- /src/FsTweet.Web/Tweet.fs: -------------------------------------------------------------------------------- 1 | namespace Tweet 2 | open User 3 | open Chessie.ErrorHandling 4 | 5 | type TweetId = TweetId of System.Guid 6 | 7 | type Post = private Post of string with 8 | static member TryCreate (post : string) = 9 | match post with 10 | | null | "" -> fail "Tweet should not be empty" 11 | | x when x.Length > 140 -> fail "Tweet should not be more than 140 characters" 12 | | x -> Post x |> ok 13 | member this.Value = 14 | let (Post post) = this 15 | post 16 | 17 | type CreateTweet = UserId -> Post -> AsyncResult 18 | 19 | type Tweet = { 20 | UserId : UserId 21 | Username : Username 22 | Id : TweetId 23 | Post : Post 24 | } 25 | 26 | module Persistence = 27 | 28 | open User 29 | open Database 30 | open System 31 | 32 | let createTweet (getDataCtx : GetDataContext) (UserId userId) (post : Post) = asyncTrial { 33 | let ctx = getDataCtx() 34 | let newTweet = ctx.Public.Tweets.Create() 35 | let newTweetId = Guid.NewGuid() 36 | 37 | newTweet.UserId <- userId 38 | newTweet.Id <- newTweetId 39 | newTweet.Post <- post.Value 40 | newTweet.TweetedAt <- DateTime.UtcNow 41 | 42 | do! submitUpdates ctx 43 | return TweetId newTweetId 44 | } 45 | -------------------------------------------------------------------------------- /src/FsTweet.Web/User.fs: -------------------------------------------------------------------------------- 1 | module User 2 | 3 | open Chessie.ErrorHandling 4 | open Chessie 5 | open BCrypt.Net 6 | 7 | type Username = private Username of string with 8 | static member TryCreate (username : string) = 9 | match username with 10 | | null | "" -> fail "Username should not be empty" 11 | | x when x.Length > 12 -> fail "Username should not be more than 12 characters" 12 | | x -> x.Trim().ToLowerInvariant() |> Username |> ok 13 | static member TryCreateAsync username = 14 | Username.TryCreate username 15 | |> mapFailure (System.Exception) 16 | |> Async.singleton 17 | |> AR 18 | member this.Value = 19 | let (Username username) = this 20 | username 21 | 22 | type UserId = UserId of int 23 | 24 | type EmailAddress = private EmailAddress of string with 25 | member this.Value = 26 | let (EmailAddress emailAddress) = this 27 | emailAddress 28 | static member TryCreate (emailAddress : string) = 29 | try 30 | new System.Net.Mail.MailAddress(emailAddress) |> ignore 31 | emailAddress.Trim().ToLowerInvariant() |> EmailAddress |> ok 32 | with 33 | | _ -> fail "Invalid Email Address" 34 | 35 | type Password = private Password of string with 36 | member this.Value = 37 | let (Password password) = this 38 | password 39 | static member TryCreate (password : string) = 40 | match password with 41 | | null | "" -> fail "Password should not be empty" 42 | | x when x.Length < 4 || x.Length > 8 -> fail "Password should contain only 4-8 characters" 43 | | x -> Password x |> ok 44 | 45 | type PasswordHash = private PasswordHash of string with 46 | member this.Value = 47 | let (PasswordHash passwordHash) = this 48 | passwordHash 49 | 50 | static member Create (password : Password) = 51 | BCrypt.HashPassword(password.Value) 52 | |> PasswordHash 53 | 54 | static member TryCreate passwordHash = 55 | try 56 | BCrypt.InterrogateHash passwordHash |> ignore 57 | PasswordHash passwordHash |> ok 58 | with 59 | | _ -> fail "Invalid Password Hash" 60 | 61 | static member VerifyPassword (password : Password) (passwordHash : PasswordHash) = 62 | BCrypt.Verify(password.Value, passwordHash.Value) 63 | 64 | type UserEmailAddress = 65 | | Verified of EmailAddress 66 | | NotVerified of EmailAddress 67 | with member this.Value = 68 | match this with 69 | | Verified e | NotVerified e -> 70 | e.Value 71 | 72 | type User = { 73 | UserId : UserId 74 | Username : Username 75 | EmailAddress : UserEmailAddress 76 | PasswordHash : PasswordHash 77 | } 78 | 79 | type FindUser = Username -> AsyncResult 80 | 81 | 82 | module Persistence = 83 | open Database 84 | open FSharp.Data.Sql 85 | open System 86 | let mapUserEntityToUser (user : DataContext.``public.UsersEntity``) = 87 | let userResult = trial { 88 | let! username = Username.TryCreate user.Username 89 | let! passwordHash = PasswordHash.TryCreate user.PasswordHash 90 | let! email = EmailAddress.TryCreate user.Email 91 | let userEmailAddress = 92 | match user.IsEmailVerified with 93 | | true -> Verified email 94 | | _ -> NotVerified email 95 | return { 96 | UserId = UserId user.Id 97 | Username = username 98 | PasswordHash = passwordHash 99 | EmailAddress = userEmailAddress 100 | } 101 | } 102 | userResult 103 | |> mapFailure Exception 104 | 105 | let mapUserEntity (user : DataContext.``public.UsersEntity``) = 106 | mapUserEntityToUser user 107 | |> Async.singleton 108 | |> AR 109 | 110 | let mapUserEntities (users : DataContext.``public.UsersEntity`` seq) = 111 | users 112 | |> Seq.map mapUserEntityToUser 113 | |> collect 114 | |> mapFailure (fun errs -> new AggregateException(errs) :> Exception) 115 | |> Async.singleton 116 | |> AR 117 | 118 | 119 | let findUser (getDataCtx : GetDataContext) (username : Username) = asyncTrial { 120 | let ctx = getDataCtx() 121 | let! userToFind = 122 | query { 123 | for u in ctx.Public.Users do 124 | where (u.Username = username.Value) 125 | } |> Seq.tryHeadAsync |> AR.catch 126 | match userToFind with 127 | | Some user -> 128 | let! user = mapUserEntity user 129 | return Some user 130 | | None -> return None 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/FsTweet.Web/UserProfile.fs: -------------------------------------------------------------------------------- 1 | namespace UserProfile 2 | 3 | module Domain = 4 | open User 5 | open System.Security.Cryptography 6 | open Chessie.ErrorHandling 7 | open System 8 | open Social.Domain 9 | 10 | type UserProfileType = 11 | | Self 12 | | OtherNotFollowing 13 | | OtherFollowing 14 | 15 | 16 | 17 | type UserProfile = { 18 | User : User 19 | GravatarUrl : string 20 | UserProfileType : UserProfileType 21 | } 22 | let gravatarUrl (emailAddress : UserEmailAddress) = 23 | use md5 = MD5.Create() 24 | emailAddress.Value 25 | |> System.Text.Encoding.Default.GetBytes 26 | |> md5.ComputeHash 27 | |> Array.map (fun b -> b.ToString("x2")) 28 | |> String.concat "" 29 | |> sprintf "http://www.gravatar.com/avatar/%s?s=200" 30 | 31 | let newProfile userProfileType user = { 32 | User = user 33 | GravatarUrl = gravatarUrl user.EmailAddress 34 | UserProfileType = userProfileType 35 | } 36 | 37 | type FindUserProfile = 38 | Username -> User option -> AsyncResult 39 | let findUserProfile 40 | (findUser : FindUser) (isFollowing : IsFollowing) 41 | (username : Username) loggedInUser = asyncTrial { 42 | 43 | match loggedInUser with 44 | | None -> 45 | let! userMayBe = findUser username 46 | return Option.map (newProfile OtherNotFollowing) userMayBe 47 | | Some (user : User) -> 48 | if user.Username = username then 49 | let userProfile = newProfile Self user 50 | return Some userProfile 51 | else 52 | let! userMayBe = findUser username 53 | match userMayBe with 54 | | Some otherUser -> 55 | let! isFollowingOtherUser = 56 | isFollowing user otherUser.UserId 57 | let userProfileType = 58 | if isFollowingOtherUser then 59 | OtherFollowing 60 | else OtherNotFollowing 61 | let userProfile = 62 | newProfile userProfileType otherUser 63 | return Some userProfile 64 | | None -> return None 65 | } 66 | 67 | module Suave = 68 | open Database 69 | open Suave.Filters 70 | open Auth.Suave 71 | open Suave 72 | open Domain 73 | open Social 74 | open User 75 | open Suave.DotLiquid 76 | open Chessie 77 | open System 78 | 79 | type UserProfileViewModel = { 80 | Username : string 81 | GravatarUrl : string 82 | IsLoggedIn : bool 83 | IsSelf : bool 84 | IsFollowing : bool 85 | UserId : int 86 | UserFeedToken : string 87 | ApiKey : string 88 | AppId : string 89 | } 90 | 91 | let newUserProfileViewModel (getStreamClient : GetStream.Client) (userProfile : UserProfile) = 92 | let (UserId userId) = userProfile.User.UserId 93 | let isSelf, isFollowing = 94 | match userProfile.UserProfileType with 95 | | Self -> true, false 96 | | OtherFollowing -> false, true 97 | | OtherNotFollowing -> false, false 98 | 99 | let userFeed = GetStream.userFeed getStreamClient userId 100 | { 101 | Username = userProfile.User.Username.Value 102 | GravatarUrl = userProfile.GravatarUrl 103 | IsLoggedIn = false 104 | IsSelf = isSelf 105 | IsFollowing = isFollowing 106 | UserId = userId 107 | UserFeedToken = userFeed.ReadOnlyToken 108 | ApiKey = getStreamClient.Config.ApiKey 109 | AppId = getStreamClient.Config.AppId 110 | } 111 | 112 | let renderUserProfilePage (vm : UserProfileViewModel) = 113 | page "user/profile.liquid" vm 114 | 115 | let renderProfileNotFound = 116 | page "not_found.liquid" "user not found" 117 | 118 | let onFindUserProfileSuccess newUserProfileViewModel isLoggedIn userProfileMayBe = 119 | match userProfileMayBe with 120 | | Some (userProfile : UserProfile) -> 121 | let vm = { 122 | newUserProfileViewModel userProfile with 123 | IsLoggedIn = isLoggedIn } 124 | renderUserProfilePage vm 125 | | None -> 126 | renderProfileNotFound 127 | 128 | let onFindUserProfileFailure (ex : Exception) = 129 | printfn "%A" ex 130 | page "server_error.liquid" "something went wrong" 131 | 132 | 133 | let renderUserProfile newUserProfileViewModel (findUserProfile : FindUserProfile) username loggedInUser ctx = async { 134 | match Username.TryCreate username with 135 | | Success validatedUsername -> 136 | let isLoggedIn = Option.isSome loggedInUser 137 | let onSuccess = 138 | onFindUserProfileSuccess newUserProfileViewModel isLoggedIn 139 | let! webpart = 140 | findUserProfile validatedUsername loggedInUser 141 | |> AR.either onSuccess onFindUserProfileFailure 142 | return! webpart ctx 143 | | Failure _ -> 144 | return! renderProfileNotFound ctx 145 | } 146 | 147 | 148 | let webpart (getDataCtx : GetDataContext) getStreamClient = 149 | let findUser = Persistence.findUser getDataCtx 150 | let isFollowing = Persistence.isFollowing getDataCtx 151 | let findUserProfile = findUserProfile findUser isFollowing 152 | let newUserProfileViewModel = newUserProfileViewModel getStreamClient 153 | let renderUserProfile = renderUserProfile newUserProfileViewModel findUserProfile 154 | pathScan "/%s" (fun username -> mayRequiresAuth(renderUserProfile username)) -------------------------------------------------------------------------------- /src/FsTweet.Web/UserSignup.fs: -------------------------------------------------------------------------------- 1 | namespace UserSignup 2 | 3 | module Domain = 4 | open Chessie.ErrorHandling 5 | open System.Security.Cryptography 6 | open User 7 | open Chessie 8 | 9 | type UserSignupRequest = { 10 | Username : Username 11 | Password : Password 12 | EmailAddress : EmailAddress 13 | } 14 | with static member TryCreate (username, password, email) = 15 | trial { 16 | let! username = Username.TryCreate username 17 | let! password = Password.TryCreate password 18 | let! emailAddress = EmailAddress.TryCreate email 19 | return { 20 | Username = username 21 | Password = password 22 | EmailAddress = emailAddress 23 | } 24 | } 25 | 26 | let base64URLEncoding bytes = 27 | let base64String = 28 | System.Convert.ToBase64String bytes 29 | base64String.TrimEnd([|'='|]) 30 | .Replace('+', '-').Replace('/', '_') 31 | 32 | type VerificationCode = private VerificationCode of string with 33 | member this.Value = 34 | let (VerificationCode verificationCode) = this 35 | verificationCode 36 | static member Create () = 37 | use rngCsp = new RNGCryptoServiceProvider() 38 | let verificationCodeLength = 15 39 | let b : byte [] = 40 | Array.zeroCreate verificationCodeLength 41 | rngCsp.GetBytes(b) 42 | base64URLEncoding b 43 | |> VerificationCode 44 | 45 | type CreateUserRequest = { 46 | Username : Username 47 | PasswordHash : PasswordHash 48 | Email : EmailAddress 49 | VerificationCode : VerificationCode 50 | } 51 | 52 | type CreateUserError = 53 | | EmailAlreadyExists 54 | | UsernameAlreadyExists 55 | | Error of System.Exception 56 | 57 | type CreateUser = 58 | CreateUserRequest -> AsyncResult 59 | type SignupEmailRequest = { 60 | Username : Username 61 | EmailAddress : EmailAddress 62 | VerificationCode : VerificationCode 63 | } 64 | type SendEmailError = SendEmailError of System.Exception 65 | 66 | type SendSignupEmail = SignupEmailRequest -> AsyncResult 67 | 68 | type UserSignupError = 69 | | CreateUserError of CreateUserError 70 | | SendEmailError of SendEmailError 71 | 72 | type SignupUser = 73 | CreateUser -> SendSignupEmail -> UserSignupRequest 74 | -> AsyncResult 75 | 76 | type VerifyUser = string -> AsyncResult 77 | 78 | let signupUser (createUser : CreateUser) 79 | (sendEmail : SendSignupEmail) 80 | (req : UserSignupRequest) = asyncTrial { 81 | 82 | let createUserReq = { 83 | PasswordHash = PasswordHash.Create req.Password 84 | Username = req.Username 85 | Email = req.EmailAddress 86 | VerificationCode = VerificationCode.Create() 87 | } 88 | 89 | let! userId = 90 | createUser createUserReq 91 | |> AR.mapFailure CreateUserError 92 | 93 | let sendEmailReq = { 94 | Username = req.Username 95 | VerificationCode = createUserReq.VerificationCode 96 | EmailAddress = createUserReq.Email 97 | } 98 | do! sendEmail sendEmailReq 99 | |> AR.mapFailure SendEmailError 100 | 101 | return userId 102 | } 103 | 104 | module Persistence = 105 | open Domain 106 | open Chessie.ErrorHandling 107 | open Database 108 | open System 109 | open FSharp.Data.Sql 110 | open Chessie 111 | open User 112 | 113 | let private mapException (ex : Exception) = 114 | match ex with 115 | | UniqueViolation "IX_Users_Email" _ -> 116 | EmailAlreadyExists 117 | | UniqueViolation "IX_Users_Username" _ -> 118 | UsernameAlreadyExists 119 | | _ -> Error ex 120 | 121 | let createUser (getDataCtx : GetDataContext) 122 | (createUserReq : CreateUserRequest) = asyncTrial { 123 | let ctx = getDataCtx () 124 | let users = ctx.Public.Users 125 | 126 | let newUser = users.Create() 127 | newUser.Email <- createUserReq.Email.Value 128 | newUser.EmailVerificationCode <- 129 | createUserReq.VerificationCode.Value 130 | newUser.Username <- createUserReq.Username.Value 131 | newUser.IsEmailVerified <- false 132 | newUser.PasswordHash <- createUserReq.PasswordHash.Value 133 | 134 | 135 | do! submitUpdates ctx 136 | |> AR.mapFailure mapException 137 | 138 | printfn "User Created %A" newUser.Id 139 | return UserId newUser.Id 140 | } 141 | 142 | let verifyUser (getDataCtx : GetDataContext) (verificationCode : string) = asyncTrial { 143 | let ctx = getDataCtx () 144 | let! userToVerify = 145 | query { 146 | for u in ctx.Public.Users do 147 | where (u.EmailVerificationCode = verificationCode) 148 | } |> Seq.tryHeadAsync |> AR.catch 149 | match userToVerify with 150 | | None -> return None 151 | | Some user -> 152 | user.EmailVerificationCode <- "" 153 | user.IsEmailVerified <- true 154 | do! submitUpdates ctx 155 | let! username = Username.TryCreateAsync user.Username 156 | return Some username 157 | } 158 | 159 | module Email = 160 | open Domain 161 | open Chessie.ErrorHandling 162 | open Email 163 | open Chessie 164 | 165 | let sendSignupEmail sendEmail signupEmailReq = asyncTrial { 166 | let verificationCode = 167 | signupEmailReq.VerificationCode.Value 168 | let placeHolders = 169 | Map.empty 170 | .Add("verification_code", verificationCode) 171 | .Add("username", signupEmailReq.Username.Value) 172 | let email = { 173 | To = signupEmailReq.EmailAddress.Value 174 | TemplateId = int64(3160924) 175 | PlaceHolders = placeHolders 176 | } 177 | do! sendEmail email 178 | |> AR.mapFailure Domain.SendEmailError 179 | } 180 | 181 | module Suave = 182 | open Suave 183 | open Suave.Filters 184 | open Suave.Operators 185 | open Suave.DotLiquid 186 | open Suave.Form 187 | open Domain 188 | open Chessie 189 | open User 190 | 191 | type UserSignupViewModel = { 192 | Username : string 193 | Email : string 194 | Password: string 195 | Error : string option 196 | } 197 | let emptyUserSignupViewModel = { 198 | Username = "" 199 | Email = "" 200 | Password = "" 201 | Error = None 202 | } 203 | 204 | let signupTemplatePath = "user/signup.liquid" 205 | 206 | let handleCreateUserError viewModel = function 207 | | EmailAlreadyExists -> 208 | let viewModel = 209 | {viewModel with Error = Some ("email already exists")} 210 | page signupTemplatePath viewModel 211 | | UsernameAlreadyExists -> 212 | let viewModel = 213 | {viewModel with Error = Some ("username already exists")} 214 | page signupTemplatePath viewModel 215 | | Error ex -> 216 | printfn "Server Error : %A" ex 217 | let viewModel = 218 | {viewModel with Error = Some ("something went wrong")} 219 | page signupTemplatePath viewModel 220 | 221 | let handleSendEmailError viewModel err = 222 | printfn "error while sending email : %A" err 223 | let viewModel = 224 | {viewModel with Error = Some ("something went wrong")} 225 | page signupTemplatePath viewModel 226 | 227 | let onUserSignupFailure viewModel err = 228 | match err with 229 | | CreateUserError cuErr -> 230 | handleCreateUserError viewModel cuErr 231 | | SendEmailError err -> 232 | handleSendEmailError viewModel err 233 | 234 | let onUserSignupSuccess viewModel _ = 235 | sprintf "/signup/success/%s" viewModel.Username 236 | |> Redirection.FOUND 237 | 238 | let handleUserSignup signupUser ctx = async { 239 | match bindEmptyForm ctx.request with 240 | | Choice1Of2 (vm : UserSignupViewModel) -> 241 | let result = 242 | UserSignupRequest.TryCreate (vm.Username, vm.Password, vm.Email) 243 | match result with 244 | | Success userSignupReq -> 245 | let! webpart = 246 | signupUser userSignupReq 247 | |> AR.either (onUserSignupSuccess vm) (onUserSignupFailure vm) 248 | return! webpart ctx 249 | | Failure msg -> 250 | let viewModel = {vm with Error = Some msg} 251 | return! page signupTemplatePath viewModel ctx 252 | | Choice2Of2 err -> 253 | let viewModel = {emptyUserSignupViewModel with Error = Some err} 254 | return! page signupTemplatePath viewModel ctx 255 | } 256 | 257 | let onVerificationSuccess username = 258 | match username with 259 | | Some (username : Username) -> 260 | page "user/verification_success.liquid" username.Value 261 | | _ -> 262 | page "not_found.liquid" "invalid verification code" 263 | 264 | let onVerificationFailure (ex : System.Exception) = 265 | printfn "%A" ex 266 | page "server_error.liquid" "error while verifying email" 267 | 268 | let handleSignupVerify (verifyUser : VerifyUser) verificationCode ctx = async { 269 | let! webpart = 270 | verifyUser verificationCode 271 | |> AR.either onVerificationSuccess onVerificationFailure 272 | return! webpart ctx 273 | } 274 | 275 | 276 | let webPart getDataCtx sendEmail = 277 | let createUser = Persistence.createUser getDataCtx 278 | let sendSignupEmail = Email.sendSignupEmail sendEmail 279 | let signupUser = Domain.signupUser createUser sendSignupEmail 280 | let verifyUser = Persistence.verifyUser getDataCtx 281 | choose [ 282 | path "/signup" 283 | >=> choose [ 284 | GET >=> page signupTemplatePath emptyUserSignupViewModel 285 | POST >=> handleUserSignup signupUser 286 | ] 287 | pathScan "/signup/success/%s" (page "user/signup_success.liquid") 288 | pathScan "/signup/verify/%s" (handleSignupVerify verifyUser) 289 | ] -------------------------------------------------------------------------------- /src/FsTweet.Web/Wall.fs: -------------------------------------------------------------------------------- 1 | namespace Wall 2 | 3 | module Domain = 4 | open User 5 | open Tweet 6 | open Chessie.ErrorHandling 7 | open System 8 | open Chessie 9 | 10 | type NotifyTweet = Tweet -> AsyncResult 11 | 12 | type PublishTweetError = 13 | | CreateTweetError of Exception 14 | | NotifyTweetError of (TweetId * Exception) 15 | 16 | type PublishTweet = 17 | CreateTweet -> NotifyTweet -> 18 | User -> Post -> AsyncResult 19 | 20 | let publishTweet createTweet notifyTweet (user : User) post = asyncTrial { 21 | let! tweetId = 22 | createTweet user.UserId post 23 | |> AR.mapFailure CreateTweetError 24 | 25 | let tweet = { 26 | Id = tweetId 27 | UserId = user.UserId 28 | Username = user.Username 29 | Post = post 30 | } 31 | do! notifyTweet tweet 32 | |> AR.mapFailure (fun ex -> NotifyTweetError(tweetId, ex)) 33 | 34 | return tweetId 35 | } 36 | 37 | module GetStream = 38 | open Tweet 39 | open User 40 | open Stream 41 | open Chessie.ErrorHandling 42 | 43 | let mapStreamResponse response = 44 | match response with 45 | | Choice1Of2 _ -> ok () 46 | | Choice2Of2 ex -> fail ex 47 | let notifyTweet (getStreamClient: GetStream.Client) (tweet : Tweet) = 48 | 49 | let (UserId userId) = tweet.UserId 50 | let (TweetId tweetId) = tweet.Id 51 | let userFeed = 52 | GetStream.userFeed getStreamClient userId 53 | 54 | let activity = new Activity(userId.ToString(), "tweet", tweetId.ToString()) 55 | activity.SetData("tweet", tweet.Post.Value) 56 | activity.SetData("username", tweet.Username.Value) 57 | 58 | userFeed.AddActivity(activity) 59 | |> Async.AwaitTask 60 | |> Async.Catch 61 | |> Async.map mapStreamResponse 62 | |> AR 63 | 64 | module Suave = 65 | open Suave 66 | open Suave.Filters 67 | open Suave.Operators 68 | open User 69 | open Auth.Suave 70 | open Suave.DotLiquid 71 | open Tweet 72 | open Chiron 73 | open Chessie 74 | open Domain 75 | open Logary 76 | open Suave.Writers 77 | 78 | type WallViewModel = { 79 | Username : string 80 | UserId : int 81 | UserFeedToken : string 82 | TimelineToken : string 83 | ApiKey : string 84 | AppId : string 85 | } 86 | 87 | type PostRequest = PostRequest of string with 88 | static member FromJson (_ : PostRequest) = json { 89 | let! post = Json.read "post" 90 | return PostRequest post 91 | } 92 | 93 | let renderWall 94 | (getStreamClient : GetStream.Client) 95 | (user : User) ctx = async { 96 | 97 | let (UserId userId) = user.UserId 98 | 99 | let userFeed = 100 | GetStream.userFeed getStreamClient userId 101 | 102 | let timeLineFeed = 103 | GetStream.timeLineFeed getStreamClient userId 104 | 105 | let vm = { 106 | Username = user.Username.Value 107 | UserId = userId 108 | UserFeedToken = userFeed.ReadOnlyToken 109 | TimelineToken = timeLineFeed.ReadOnlyToken 110 | ApiKey = getStreamClient.Config.ApiKey 111 | AppId = getStreamClient.Config.AppId} 112 | 113 | return! page "user/wall.liquid" vm ctx 114 | 115 | } 116 | 117 | let onPublishTweetSuccess (TweetId id) = 118 | ["id", Json.String (id.ToString())] 119 | |> Map.ofList 120 | |> Json.Object 121 | |> JSON.ok 122 | 123 | let onPublishTweetFailure (user : User) (err : PublishTweetError) = 124 | let (UserId userId) = user.UserId 125 | 126 | let msg = 127 | Message.event Error "Tweet Notification Error" 128 | |> Message.setField "userId" userId 129 | 130 | match err with 131 | | NotifyTweetError (tweetId, ex) -> 132 | let (TweetId tId) = tweetId 133 | msg 134 | |> Message.addExn ex 135 | |> Message.setField "tweetId" tId 136 | |> setUserData "err" 137 | >=> onPublishTweetSuccess tweetId 138 | | CreateTweetError ex -> 139 | msg 140 | |> Message.addExn ex 141 | |> setUserData "err" 142 | >=> JSON.internalError 143 | 144 | 145 | let handleNewTweet publishTweet (user : User) ctx = async { 146 | match JSON.deserialize Json.tryDeserialize ctx.request with 147 | | Success (PostRequest post) -> 148 | match Post.TryCreate post with 149 | | Success post -> 150 | let! webpart = 151 | publishTweet user post 152 | |> AR.either onPublishTweetSuccess (onPublishTweetFailure user) 153 | return! webpart ctx 154 | | Failure err -> 155 | return! JSON.badRequest err ctx 156 | | Failure err -> 157 | return! JSON.badRequest err ctx 158 | } 159 | 160 | let webpart getDataCtx getStreamClient = 161 | let createTweet = Persistence.createTweet getDataCtx 162 | let notifyTweet = GetStream.notifyTweet getStreamClient 163 | let publishTweet = publishTweet createTweet notifyTweet 164 | choose [ 165 | path "/wall" >=> requiresAuth (renderWall getStreamClient) 166 | POST >=> path "/tweets" 167 | >=> requiresAuth2 (handleNewTweet publishTweet) 168 | ] -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 20px; 3 | padding-bottom: 20px; 4 | } 5 | 6 | @media (min-width: 768px) { 7 | .container { 8 | max-width: 730px; 9 | } 10 | } 11 | .container-narrow > hr { 12 | margin: 30px 0; 13 | } 14 | 15 | /* Guest Home Page */ 16 | 17 | .jumbotron { 18 | text-align: center; 19 | border-bottom: 1px solid #e5e5e5; 20 | } 21 | .jumbotron .btn { 22 | padding: 14px 24px; 23 | font-size: 21px; 24 | } 25 | 26 | /* Sign up & Sign In Forms */ 27 | 28 | .form-signin, .form-signup{ 29 | max-width: 330px; 30 | padding: 15px; 31 | margin: 0 auto; 32 | } 33 | .form-signin .form-signin-heading, 34 | .form-signup .form-signup-heading { 35 | margin-bottom: 10px; 36 | } 37 | 38 | .form-signin .form-control, 39 | .form-signup .form-control { 40 | position: relative; 41 | height: auto; 42 | -webkit-box-sizing: border-box; 43 | -moz-box-sizing: border-box; 44 | box-sizing: border-box; 45 | padding: 10px; 46 | font-size: 16px; 47 | margin-bottom: 5px; 48 | } 49 | 50 | .form-signin .form-control:focus, 51 | .form-signup .form-control:focus{ 52 | z-index: 2; 53 | } 54 | .form-signin input[type="email"], 55 | .form-signup input[type="email"] { 56 | border-bottom-right-radius: 0; 57 | border-bottom-left-radius: 0; 58 | } 59 | 60 | .form-signup input[type="text"] { 61 | border-bottom-right-radius: 0; 62 | border-bottom-left-radius: 0; 63 | } 64 | 65 | .form-signin input[type="password"], 66 | .form-signup input[type="password"] { 67 | border-top-left-radius: 0; 68 | border-top-right-radius: 0; 69 | } 70 | 71 | p.well { 72 | margin-top: 50px 73 | } 74 | 75 | .tweet_button { 76 | margin-top: 5px; 77 | } 78 | 79 | .tweet_container { 80 | background-color: rgb(240, 247, 254); 81 | margin-bottom: 10px; 82 | padding: 5px; 83 | } 84 | 85 | .username { 86 | margin-top: 2px; 87 | font-size: x-large; 88 | color: grey; 89 | } 90 | 91 | #wall { 92 | margin-top: 50px 93 | } 94 | 95 | .tweet_read_view { 96 | background-color: rgb(240, 247, 254); 97 | margin-top: 12px; 98 | padding: 15px; 99 | font-size: larger 100 | } 101 | 102 | .tweet_read_view > span { 103 | font-size: smaller 104 | } 105 | 106 | #tweet { 107 | font-size: large 108 | } 109 | 110 | .gravatar { 111 | border: 1px solid black 112 | } 113 | 114 | .gravatar_name { 115 | margin-top: 4px; 116 | font-size: 30px; 117 | color: grey; 118 | } 119 | 120 | .user-card { 121 | margin-top: 10px; 122 | padding: 5px; 123 | font-size: 25px; 124 | } 125 | 126 | /* Start by setting display:none to make this hidden. 127 | Then we position it in relation to the viewport window 128 | with position:fixed. Width, height, top and left speak 129 | speak for themselves. Background we set to 80% white with 130 | our animation centered, and no-repeating */ 131 | .modal { 132 | display: none; 133 | position: fixed; 134 | z-index: 1000; 135 | top: 0; 136 | left: 0; 137 | height: 100%; 138 | width: 100%; 139 | background: rgba( 255, 255, 255, .8 ) 140 | url('http://sampsonresume.com/labs/pIkfp.gif') 141 | 50% 50% 142 | no-repeat; 143 | } 144 | 145 | /* When the body has the loading class, we turn 146 | the scrollbar off with overflow:hidden */ 147 | body.loading { 148 | overflow: hidden; 149 | } 150 | 151 | /* Anytime the body has the loading class, our 152 | modal element will be visible */ 153 | body.loading .modal { 154 | display: block; 155 | } -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/images/FsTweetLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demystifyfp/FsTweet/a3fbd1431b829566c3179d7093eb53f0b99c7ac3/src/FsTweet.Web/assets/images/FsTweetLogo.png -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demystifyfp/FsTweet/a3fbd1431b829566c3179d7093eb53f0b99c7ac3/src/FsTweet.Web/assets/images/favicon.ico -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/js/lib/getstream.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.stream=e():t.stream=e()}(this,function(){return function(t){function e(i){if(n[i])return n[i].exports;var r=n[i]={exports:{},id:i,loaded:!1};return t[i].call(r.exports,r,r.exports,e),r.loaded=!0,r.exports}var n={};return e.m=t,e.c=n,e.p="dist/",e(0)}([function(t,e,n){(function(e){"use strict";function i(t,n,i,s){if("undefined"!=typeof e&&e.env.STREAM_URL&&!t){var o=/https\:\/\/(\w+)\:(\w+)\@([\w-]*).*\?app_id=(\d+)/.exec(e.env.STREAM_URL);t=o[1],n=o[2];var a=o[3];i=o[4],void 0===s&&(s={}),"getstream"!==a&&(s.location=a)}return new r(t,n,i,s)}var r=n(2),s=n(5),o=n(3);t.exports.connect=i,t.exports.errors=s,t.exports.request=o,t.exports.Client=r}).call(e,n(1))},function(t,e){function n(){throw new Error("setTimeout has not been defined")}function i(){throw new Error("clearTimeout has not been defined")}function r(t){if(h===setTimeout)return setTimeout(t,0);if((h===n||!h)&&setTimeout)return h=setTimeout,setTimeout(t,0);try{return h(t,0)}catch(e){try{return h.call(null,t,0)}catch(e){return h.call(this,t,0)}}}function s(t){if(l===clearTimeout)return clearTimeout(t);if((l===i||!l)&&clearTimeout)return l=clearTimeout,clearTimeout(t);try{return l(t)}catch(e){try{return l.call(null,t)}catch(e){return l.call(this,t)}}}function o(){v&&d&&(v=!1,d.length?p=d.concat(p):_=-1,p.length&&a())}function a(){if(!v){var t=r(o);v=!0;for(var e=p.length;e;){for(d=p,p=[];++_1)for(var n=1;n>18&63,s=c>>12&63,o=c>>6&63,a=63&c,d[l++]=u.charAt(r)+u.charAt(s)+u.charAt(o)+u.charAt(a);while(h>>0;if("function"!=typeof t)throw new TypeError(t+" is not a function");for(arguments.length>1&&(n=e),i=0;i299)&&i.error){t=new Error("CouchDB error: "+(i.error.reason||i.error.error));for(var r in i)t[r]=i[r];return e(t,n,i)}return e(t,n,i)}"string"==typeof t&&(t={uri:t}),t.json=!0,t.body&&(t.json=t.body),delete t.body,e=e||r;var s=n(t,i);return s},t.exports=n},function(t,e,n){"use strict";var i=n(5),r=n(6),s=n(7),o=function(){this.initialize.apply(this,arguments)};o.prototype={initialize:function(t,e,n,i){this.client=t,this.slug=e,this.userId=n,this.id=this.slug+":"+this.userId,this.token=i,this.feedUrl=this.id.replace(":","/"),this.feedTogether=this.id.replace(":",""),this.signature=this.feedTogether+" "+this.token,this.notificationChannel="site-"+this.client.appId+"-feed-"+this.feedTogether},addActivity:function(t,e){return t=this.client.signActivity(t),this.client.post({url:"feed/"+this.feedUrl+"/",body:t,signature:this.signature},e)},removeActivity:function(t,e){var n=t.foreignId?t.foreignId:t,i={};return t.foreignId&&(i.foreign_id="1"),this.client.delete({url:"feed/"+this.feedUrl+"/"+n+"/",qs:i,signature:this.signature},e)},addActivities:function(t,e){t=this.client.signActivities(t);var n={activities:t},i=this.client.post({url:"feed/"+this.feedUrl+"/",body:n,signature:this.signature},e);return i},follow:function(t,e,n,i){r.validateFeedSlug(t),r.validateUserId(e);var s,o=arguments[arguments.length-1];i=o.call?o:void 0;var a=t+":"+e;n&&!n.call&&"undefined"!=typeof n.limit&&null!==n.limit&&(s=n.limit);var c={target:a};return"undefined"!=typeof s&&null!==s&&(c.activity_copy_limit=s),this.client.post({url:"feed/"+this.feedUrl+"/following/",body:c,signature:this.signature},i)},unfollow:function(t,e,n,i){var s={},o={};"function"==typeof n&&(i=n),"object"==typeof n&&(s=n),"boolean"==typeof s.keepHistory&&s.keepHistory&&(o.keep_history="1"),r.validateFeedSlug(t),r.validateUserId(e);var a=t+":"+e,c=this.client.delete({url:"feed/"+this.feedUrl+"/following/"+a+"/",qs:o,signature:this.signature},i);return c},following:function(t,e){return void 0!==t&&t.filter&&(t.filter=t.filter.join(",")),this.client.get({url:"feed/"+this.feedUrl+"/following/",qs:t,signature:this.signature},e)},followers:function(t,e){return void 0!==t&&t.filter&&(t.filter=t.filter.join(",")),this.client.get({url:"feed/"+this.feedUrl+"/followers/",qs:t,signature:this.signature},e)},get:function(t,e){return t&&t.mark_read&&t.mark_read.join&&(t.mark_read=t.mark_read.join(",")),t&&t.mark_seen&&t.mark_seen.join&&(t.mark_seen=t.mark_seen.join(",")),this.client.get({url:"feed/"+this.feedUrl+"/",qs:t,signature:this.signature},e)},getFayeClient:function(){return this.client.getFayeClient()},subscribe:function(t){if(!this.client.appId)throw new i.SiteError("Missing app id, which is needed to subscribe, use var client = stream.connect(key, secret, appId);");return this.client.subscriptions["/"+this.notificationChannel]={token:this.token,userId:this.notificationChannel},this.getFayeClient().subscribe("/"+this.notificationChannel,t)},getReadOnlyToken:function(){var t=""+this.slug+this.userId;return s.JWTScopeToken(this.client.apiSecret,"*","read",{feedId:t,expireTokens:this.client.expireTokens})},getReadWriteToken:function(){var t=""+this.slug+this.userId;return s.JWTScopeToken(this.client.apiSecret,"*","*",{feedId:t,expireTokens:this.client.expireTokens})}},t.exports=o},function(t,e){"use strict";function n(t,e){this.message=t,Error.call(this,this.message),r?Error.captureStackTrace(this,e):s?this.stack=(new Error).stack:this.stack=""}var i=t.exports,r="function"==typeof Error.captureStackTrace,s=!!(new Error).stack;i._Abstract=n,n.prototype=new Error,i.FeedError=function(t){n.call(this,t)},i.FeedError.prototype=new n,i.SiteError=function(t){n.call(this,t)},i.SiteError.prototype=new n,i.MissingSchemaError=function(t){n.call(this,t)},i.MissingSchemaError.prototype=new n},function(t,e,n){"use strict";function i(t){var e=t.split(":");if(2!==e.length)throw new a.FeedError("Invalid feedId, expected something like user:1 got "+t);var n=e[0],i=e[1];return r(n),s(i),t}function r(t){var e=c.test(t);if(!e)throw new a.FeedError("Invalid feedSlug, please use letters, numbers or _ got: "+t);return t}function s(t){var e=c.test(t);if(!e)throw new a.FeedError("Invalid feedSlug, please use letters, numbers or _ got: "+t);return t}function o(t){return t.replace(/[!'()*]/g,function(t){return"%"+t.charCodeAt(0).toString(16).toUpperCase()})}var a=n(5),c=/^[\w-]+$/;e.validateFeedId=i,e.validateFeedSlug=r,e.validateUserId=s,e.rfc3986=o},function(t,e,n){"use strict";function i(t){var e=t.replace(/\+/g,"-").replace(/\//g,"_");return e.replace(/^=+/,"").replace(/=+$/,"")}function r(t){try{return f.atob(a(t))}catch(t){if("InvalidCharacterError"===t.name)return;throw t}}function s(t){if("object"==typeof t)return t;try{return JSON.parse(t)}catch(t){return}}function o(t){var e=4,n=t.length%e;if(!n)return t;for(var i=e-n;i--;)t+="=";return t}function a(t){var e=o(t).replace(/\-/g,"+").replace(/_/g,"/");return e}function c(t){var e=t.split(".",1)[0];return s(r(e))}var u=n(8),h=n(9),l=/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/,f=n(10);e.headerFromJWS=c,e.sign=function(t,e){var n=new u.createHash("sha1").update(t).digest(),r=u.createHmac("sha1",n),s=r.update(e).digest("base64"),o=i(s);return o},e.JWTScopeToken=function(t,e,n,i){var r=i||{},s=!r.expireTokens||!r.expireTokens,o={resource:e,action:n};r.feedId&&(o.feed_id=r.feedId),r.userId&&(o.user_id=r.userId);var a=h.sign(o,t,{algorithm:"HS256",noTimestamp:s});return a},e.isJWTSignature=function(t){var e=t.split(" ")[1]||t;return l.test(e)&&!!c(e)}},function(t,e){},function(t,e){"use strict"},function(t,e,n){!function(){function t(t){this.message=t}var n=e,i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";t.prototype=new Error,t.prototype.name="InvalidCharacterError",n.btoa||(n.btoa=function(e){for(var n,r,s=String(e),o=0,a=i,c="";s.charAt(0|o)||(a="=",o%1);c+=a.charAt(63&n>>8-o%1*8)){if(r=s.charCodeAt(o+=.75),r>255)throw new t("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.");n=n<<8|r}return c}),n.atob||(n.atob=function(e){var n=String(e).replace(/[=]+$/,"");if(n.length%4==1)throw new t("'atob' failed: The string to be decoded is not correctly encoded.");for(var r,s,o=0,a=0,c="";s=n.charAt(a++);~s&&(r=o%4?64*r+s:s,o++%4)?c+=String.fromCharCode(255&r>>(-2*o&6)):0)s=i.indexOf(s);return c})}()},function(t,e,n){"use strict";var i=n(12);t.exports=i},function(t,e,n){"use strict";var i=n(13),r=0,s=1,o=2,a=function(t){return t},c=function(t){throw t},u=function(t){if(this._state=r,this._onFulfilled=[],this._onRejected=[],"function"==typeof t){var e=this;t(function(t){p(e,t)},function(t){_(e,t)})}};u.prototype.then=function(t,e){var n=new u;return h(this,t,n),l(this,e,n),n},u.prototype.catch=function(t){return this.then(null,t)};var h=function(t,e,n){"function"!=typeof e&&(e=a);var i=function(t){f(e,t,n)};t._state===r?t._onFulfilled.push(i):t._state===s&&i(t._value)},l=function(t,e,n){"function"!=typeof e&&(e=c);var i=function(t){f(e,t,n)};t._state===r?t._onRejected.push(i):t._state===o&&i(t._reason)},f=function(t,e,n){i(function(){d(t,e,n)})},d=function(t,e,n){var i;try{i=t(e)}catch(t){return _(n,t)}i===n?_(n,new TypeError("Recursive promise chain detected")):p(n,i)},p=function(t,e){var n,i,r=!1;try{if(n=typeof e,i=null!==e&&("function"===n||"object"===n)&&e.then,"function"!=typeof i)return v(t,e);i.call(e,function(e){r^(r=!0)&&p(t,e)},function(e){r^(r=!0)&&_(t,e)})}catch(e){if(!(r^(r=!0)))return;_(t,e)}},v=function(t,e){if(t._state===r){t._state=s,t._value=e,t._onRejected=[];for(var n,i=t._onFulfilled;n=i.shift();)n(e)}},_=function(t,e){if(t._state===r){t._state=o,t._reason=e,t._onFulfilled=[];for(var n,i=t._onRejected;n=i.shift();)n(e)}};u.resolve=function(t){return new u(function(e,n){e(t)})},u.reject=function(t){return new u(function(e,n){n(t)})},u.all=function(t){return new u(function(e,n){var i,r=[],s=t.length;if(0===s)return e(r);for(i=0;ih){for(var e=0,n=a.length-u;e=Math.pow(2,32)&&(this._messageId=0),this._messageId.toString(36)},_receiveMessage:function(t){var e,n=t.id;void 0!==t.successful&&(e=this._responseCallbacks[n],delete this._responseCallbacks[n]),this.pipeThroughExtensions("incoming",t,null,function(t){t&&(t.advice&&this._handleAdvice(t.advice),this._deliverMessage(t),e&&e[0].call(e[1],t))},this)},_handleAdvice:function(t){u(this._advice,t),this._dispatcher.timeout=this._advice.timeout/1e3,this._advice.reconnect===this.HANDSHAKE&&this._state!==this.DISCONNECTED&&(this._state=this.UNCONNECTED,this._dispatcher.clientId=null,this._cycleConnection())},_deliverMessage:function(t){t.channel&&void 0!==t.data&&(this.info("Client ? calling listeners for ? with ?",this._dispatcher.clientId,t.channel,t.data),this._channels.distributeMessage(t))},_cycleConnection:function(){this._connectRequest&&(this._connectRequest=null,this.info("Closed connection for ?",this._dispatcher.clientId));var t=this;e.setTimeout(function(){t.connect()},this._advice.interval)}});u(b.prototype,l),u(b.prototype,d),u(b.prototype,f),u(b.prototype,g),t.exports=b}).call(e,function(){return this}())},function(t,e,n){"use strict";var i=n(22);t.exports=function(t,e){"function"!=typeof t&&(e=t,t=Object);var n=function(){return this.initialize?this.initialize.apply(this,arguments)||this:this},r=function(){};return r.prototype=t.prototype,n.prototype=new r,i(n.prototype,e),n}},function(t,e){"use strict";t.exports=function(t,e,n){if(!e)return t;for(var i in e)e.hasOwnProperty(i)&&(t.hasOwnProperty(i)&&n===!1||t[i]!==e[i]&&(t[i]=e[i]));return t}},function(t,e){"use strict";t.exports={isURI:function(t){return t&&t.protocol&&t.host&&t.path},isSameOrigin:function(t){return t.protocol===location.protocol&&t.hostname===location.hostname&&t.port===location.port},parse:function(t){if("string"!=typeof t)return t;var e,n,i,r,s,o,a={},c=function(e,n){t=t.replace(n,function(t){return a[e]=t,""}),a[e]=a[e]||""};for(c("protocol",/^[a-z]+\:/i),c("host",/^\/\/[^\/\?#]+/),/^\//.test(t)||a.host||(t=location.pathname.replace(/[^\/]*$/,"")+t),c("pathname",/^[^\?#]*/),c("search",/^\?[^#]*/),c("hash",/^#.*/),a.protocol=a.protocol||location.protocol,a.host?(a.host=a.host.substr(2),e=a.host.split(":"),a.hostname=e[0],a.port=e[1]||""):(a.host=location.host,a.hostname=location.hostname,a.port=location.port), 2 | a.pathname=a.pathname||"/",a.path=a.pathname+a.search,n=a.search.replace(/^\?/,""),i=n?n.split("&"):[],o={},r=0,s=i.length;r0;)c();a=!1}},h=function(){o+=1,u()};h()}}},function(t,e){(function(e){"use strict";var n={_registry:[],on:function(t,e,n,i){var r=function(){n.call(i)};t.addEventListener?t.addEventListener(e,r,!1):t.attachEvent("on"+e,r),this._registry.push({_element:t,_type:e,_callback:n,_context:i,_handler:r})},detach:function(t,e,n,i){for(var r,s=this._registry.length;s--;)r=this._registry[s],t&&t!==r._element||e&&e!==r._type||n&&n!==r._callback||i&&i!==r._context||(r._element.removeEventListener?r._element.removeEventListener(r._type,r._handler,!1):r._element.detachEvent("on"+r._type,r._handler),this._registry.splice(s,1),r=null)}};void 0!==e.onunload&&n.on(e,"unload",n.detach,n),t.exports={Event:n}}).call(e,function(){return this}())},function(t,e,n){"use strict";var i=n(24);t.exports=function(t,e){for(var n in t)if(i.indexOf(e,n)<0)throw new Error("Unrecognized option: "+n)}},function(t,e,n){(function(e){"use strict";var i=n(12);t.exports={then:function(t,e){var n=this;return this._promise||(this._promise=new i(function(t,e){n._resolve=t,n._reject=e})),0===arguments.length?this._promise:this._promise.then(t,e)},callback:function(t,e){return this.then(function(n){t.call(e,n)})},errback:function(t,e){return this.then(null,function(n){t.call(e,n)})},timeout:function(t,n){this.then();var i=this;this._timer=e.setTimeout(function(){i._reject(n)},1e3*t)},setDeferredStatus:function(t,n){this._timer&&e.clearTimeout(this._timer),this.then(),"succeeded"===t?this._resolve(n):"failed"===t?this._reject(n):delete this._promise}}}).call(e,function(){return this}())},function(t,e,n){"use strict";var i=n(22),r=n(29),s={countListeners:function(t){return this.listeners(t).length},bind:function(t,e,n){var i=Array.prototype.slice,r=function(){e.apply(n,i.call(arguments))};return this._listeners=this._listeners||[],this._listeners.push([t,e,n,r]),this.on(t,r)},unbind:function(t,e,n){this._listeners=this._listeners||[];for(var i,r=this._listeners.length;r--;)i=this._listeners[r],i[0]===t&&(!e||i[1]===e&&i[2]===n)&&(this._listeners.splice(r,1),this.removeListener(t,i[3]))}};i(s,r.prototype),s.trigger=s.emit,t.exports=s},function(t,e){function n(t,e){if(t.indexOf)return t.indexOf(e);for(var n=0;n1&&this._connectMessage&&(this._connectMessage.advice={timeout:0}),this._resolvePromise(this.request(this._outbox)),this._connectMessage=null,this._outbox=[]},_flushLargeBatch:function(){var t=this.encode(this._outbox);if(!(t.length1&&(i=o[r]),i=i||o["CGI_"+s]):(i=o[r]||o[s],i&&!o[r]&&console.warn("The environment variable "+s+" is discouraged. Use "+r+".")),i}}}}),{get:function(t,e,n,i,r){var s=t.endpoint;a.asyncEach(this._transports,function(s,o){var c=s[0],u=s[1],h=t.endpointFor(c);return a.indexOf(n,c)>=0?o():a.indexOf(e,c)<0?(u.isUsable(t,h,function(){}),o()):void u.isUsable(t,h,function(e){if(!e)return o();var n=u.hasOwnProperty("create")?u.create(t,h):new u(t,h);i.call(r,n)})},function(){throw new Error("Could not find a usable connection type for "+o.stringify(s))})},register:function(t,e){this._transports.push([t,e]),e.prototype.connectionType=t},getConnectionTypes:function(){return a.map(this._transports,function(t){return t[0]})},_transports:[]});c(f.prototype,u),c(f.prototype,h),t.exports=f}).call(e,n(1))},function(t,e){(function(e){"use strict";t.exports={addTimeout:function(t,n,i,r){if(this._timeouts=this._timeouts||{},!this._timeouts.hasOwnProperty(t)){var s=this;this._timeouts[t]=e.setTimeout(function(){delete s._timeouts[t],i.call(r)},1e3*n)}},removeTimeout:function(t){this._timeouts=this._timeouts||{};var n=this._timeouts[t];n&&(e.clearTimeout(n),delete this._timeouts[t])},removeAllTimeouts:function(){this._timeouts=this._timeouts||{};for(var t in this._timeouts)this.removeTimeout(t)}}}).call(e,function(){return this}())},function(t,e,n){(function(e){"use strict";var i=n(21),r=n(12),s=n(38),o=n(23),a=n(25),c=n(39),u=n(22),h=n(19),l=n(40),f=n(27),d=n(35),p=u(i(d,{UNCONNECTED:1,CONNECTING:2,CONNECTED:3,batching:!1,isUsable:function(t,e){this.callback(function(){t.call(e,!0)}),this.errback(function(){t.call(e,!1)}),this.connect()},request:function(t){this._pending=this._pending||new s;for(var e=0,n=t.length;e=200&&o<300||304===o||1223===o;if(void 0!==e.onbeforeunload&&s.Event.detach(e,"beforeunload",c),n.onreadystatechange=function(){},n=null,!u)return r._handleError(t);try{i=JSON.parse(a)}catch(t){}i?r._receive(i):r._handleError(t)}},n.send(this.encode(t)),n}}),{isUsable:function(t,e,n,i){var s="ReactNative"===navigator.product||r.isSameOrigin(e);n.call(i,s)}});t.exports=u}).call(e,function(){return this}())},function(t,e,n){(function(e){"use strict";var i=n(21),r=n(38),s=n(23),o=n(22),a=n(19),c=n(35),u=o(i(c,{encode:function(t){return"message="+encodeURIComponent(a(t))},request:function(t){var n,i=e.XDomainRequest?XDomainRequest:XMLHttpRequest,r=new i,o=++u._id,a=this._dispatcher.headers,c=this;if(r.open("POST",s.stringify(this.endpoint),!0),r.setRequestHeader){r.setRequestHeader("Pragma","no-cache");for(n in a)a.hasOwnProperty(n)&&r.setRequestHeader(n,a[n])}var h=function(){return!!r&&(u._pending.remove(o),r.onload=r.onerror=r.ontimeout=r.onprogress=null,void(r=null))};return r.onload=function(){var e;try{e=JSON.parse(r.responseText)}catch(t){}h(),e?c._receive(e):c._handleError(t)},r.onerror=r.ontimeout=function(){h(),c._handleError(t)},r.onprogress=function(){},i===e.XDomainRequest&&u._pending.add({id:o,xhr:r}),r.send(this.encode(t)),r}}),{_id:0,_pending:new r,isUsable:function(t,n,i,r){if(s.isSameOrigin(n))return i.call(r,!1);if(e.XDomainRequest)return i.call(r,n.protocol===location.protocol);if(e.XMLHttpRequest){var o=new XMLHttpRequest;return i.call(r,void 0!==o.withCredentials)}return i.call(r,!1)}});t.exports=u}).call(e,function(){return this}())},function(t,e,n){(function(e){"use strict";var i=n(21),r=n(23),s=n(39),o=n(22),a=n(19),c=n(35),u=o(i(c,{encode:function(t){var e=s(this.endpoint);return e.query.message=a(t),e.query.jsonp="__jsonp"+u._cbCount+"__",r.stringify(e)},request:function(t){var n=document.getElementsByTagName("head")[0],i=document.createElement("script"),o=u.getCallbackName(),c=s(this.endpoint),h=this;c.query.message=a(t),c.query.jsonp=o;var l=function(){if(!e[o])return!1;e[o]=void 0;try{delete e[o]}catch(t){}i.parentNode.removeChild(i)};return e[o]=function(t){l(),h._receive(t)},i.type="text/javascript",i.src=r.stringify(c),n.appendChild(i),i.onerror=function(){l(),h._handleError(t)},{abort:l}}}),{_cbCount:0,getCallbackName:function(){return this._cbCount+=1,"__jsonp"+this._cbCount+"__"},isUsable:function(t,e,n,i){n.call(i,!0)}});t.exports=u}).call(e,function(){return this}())},function(t,e,n){"use strict";var i=n(22),r=function(t,e){this.message=t,this.options=e,this.attempts=0};i(r.prototype,{getTimeout:function(){return this.options.timeout},getInterval:function(){return this.options.interval},isDeliverable:function(){var t=this.options.attempts,e=this.attempts,n=this.options.deadline,i=(new Date).getTime();return!(void 0!==t&&e>=t)&&!(void 0!==n&&i>n)},send:function(){this.attempts+=1},succeed:function(){},fail:function(){},abort:function(){}}),t.exports=r},function(t,e,n){"use strict";var i=n(21),r=n(31),s=i({initialize:function(t,e,n){this.code=t,this.params=Array.prototype.slice.call(e),this.message=n},toString:function(){return this.code+":"+this.params.join(",")+":"+this.message}});s.parse=function(t){if(t=t||"",!r.ERROR.test(t))return new s(null,[],t);var e=t.split(":"),n=parseInt(e[0]),i=e[1].split(","),t=e[2];return new s(n,i,t)};var o={versionMismatch:[300,"Version mismatch"],conntypeMismatch:[301,"Connection types not supported"],extMismatch:[302,"Extension mismatch"],badRequest:[400,"Bad request"],clientUnknown:[401,"Unknown client"],parameterMissing:[402,"Missing required parameter"],channelForbidden:[403,"Forbidden channel"],channelUnknown:[404,"Unknown channel"],channelInvalid:[405,"Invalid channel"],extUnknown:[406,"Unknown extension"],publishFailed:[407,"Failed to publish"],serverError:[500,"Internal server error"]};for(var a in o)(function(t){s[t]=function(){return new s(o[t][0],arguments,o[t][1]).toString()}})(a);t.exports=s},function(t,e,n){"use strict";var i=n(22),r=n(18),s={addExtension:function(t){this._extensions=this._extensions||[],this._extensions.push(t),t.added&&t.added(this)},removeExtension:function(t){if(this._extensions)for(var e=this._extensions.length;e--;)this._extensions[e]===t&&(this._extensions.splice(e,1),t.removed&&t.removed(this))},pipeThroughExtensions:function(t,e,n,i,r){if(this.debug("Passing through ? extensions: ?",t,e),!this._extensions)return i.call(r,e);var s=this._extensions.slice(),o=function(e){if(!e)return i.call(r,e);var a=s.shift();if(!a)return i.call(r,e);var c=a[t];return c?void(c.length>=3?a[t](e,n,o):a[t](e,o)):o(e)};o(e)}};i(s,r),t.exports=s},function(t,e,n){"use strict";var i=n(21),r=n(27);t.exports=i(r)},function(t,e,n){"use strict";var i=n(21),r=n(22),s=n(27),o=i({initialize:function(t,e,n,i){this._client=t,this._channels=e,this._callback=n,this._context=i,this._cancelled=!1},withChannel:function(t,e){return this._withChannel=[t,e],this},apply:function(t,e){var n=e[0];this._callback&&this._callback.call(this._context,n.data),this._withChannel&&this._withChannel[0].call(this._withChannel[1],n.channel,n.data)},cancel:function(){this._cancelled||(this._client.unsubscribe(this._channels,this),this._cancelled=!0)},unsubscribe:function(){this.cancel()}});r(o.prototype,s),t.exports=o}])}); -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/js/profile.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | let client = stream.connect(fsTweet.stream.apiKey, null, fsTweet.stream.appId); 3 | let userFeed = client.feed("user", fsTweet.user.id, fsTweet.user.feedToken); 4 | 5 | userFeed.get({ 6 | limit: 25 7 | }).then(function(body) { 8 | $(body.results.reverse()).each(function(index, tweet){ 9 | renderTweet($("#tweets"), tweet); 10 | }); 11 | }) 12 | }); -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/js/social.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | 3 | $("#follow").on('click', function(){ 4 | var $this = $(this); 5 | var userId = $this.data('user-id'); 6 | $.ajax({ 7 | url : "/follow", 8 | type: "post", 9 | data: JSON.stringify({userId : userId}), 10 | contentType: "application/json" 11 | }).done(function(){ 12 | $this.attr('id', 'unfollow'); 13 | $this.html('Following'); 14 | $this.addClass('disabled'); 15 | }).fail(function(jqXHR, textStatus, errorThrown) { 16 | console.log({jqXHR : jqXHR, textStatus : textStatus, errorThrown: errorThrown}) 17 | alert("something went wrong!") 18 | }); 19 | }); 20 | 21 | var usersTemplate = ` 22 | {{#users}} 23 |
24 | @{{username}} 25 |
26 | {{/users}}`; 27 | 28 | 29 | function renderUsers(data, $body, $count) { 30 | var htmlOutput = Mustache.render(usersTemplate, data); 31 | $body.html(htmlOutput); 32 | $count.html(data.users.length); 33 | } 34 | 35 | 36 | (function loadFollowers () { 37 | var url = "/" + fsTweet.user.id + "/followers" 38 | $.getJSON(url, function(data){ 39 | renderUsers(data, $("#followers"), $("#followersCount")) 40 | }) 41 | })(); 42 | 43 | (function loadFollowingUsers() { 44 | var url = "/" + fsTweet.user.id + "/following" 45 | $.getJSON(url, function(data){ 46 | renderUsers(data, $("#following"), $("#followingCount")) 47 | }) 48 | })(); 49 | 50 | }); -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/js/tweet.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | var timeAgo = function () { 3 | return function(val, render) { 4 | return moment(render(val) + "Z").fromNow() 5 | }; 6 | } 7 | 8 | var template = ` 9 |
10 | @{{tweet.username}} - {{#timeAgo}}{{tweet.time}}{{/timeAgo}} 11 |

{{tweet.tweet}}

12 |
13 | ` 14 | 15 | window.renderTweet = function($parent, tweet) { 16 | var htmlOutput = Mustache.render(template, { 17 | "tweet" : tweet, 18 | "timeAgo" : timeAgo 19 | }); 20 | $parent.prepend(htmlOutput); 21 | }; 22 | 23 | $body = $("body"); 24 | 25 | $(document).on({ 26 | ajaxStart: function() { $body.addClass("loading"); }, 27 | ajaxStop: function() { $body.removeClass("loading"); } 28 | }); 29 | 30 | }); -------------------------------------------------------------------------------- /src/FsTweet.Web/assets/js/wall.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | $("#tweetForm").submit(function(event){ 3 | var $this = $(this); 4 | var $tweet = $("#tweet"); 5 | event.preventDefault(); 6 | $this.prop('disabled', true); 7 | $.ajax({ 8 | url : "/tweets", 9 | type: "post", 10 | data: JSON.stringify({post : $tweet.val()}), 11 | contentType: "application/json" 12 | }).done(function(){ 13 | $this.prop('disabled', false); 14 | $tweet.val(''); 15 | }).fail(function(jqXHR, textStatus, errorThrown) { 16 | console.log({jqXHR : jqXHR, textStatus : textStatus, errorThrown: errorThrown}) 17 | alert("something went wrong!") 18 | }); 19 | 20 | }); 21 | 22 | $("textarea[maxlength]").on("propertychange input", function() { 23 | if (this.value.length > this.maxlength) { 24 | this.value = this.value.substring(0, this.maxlength); 25 | } 26 | }); 27 | 28 | let client = stream.connect(fsTweet.stream.apiKey, null, fsTweet.stream.appId); 29 | let userFeed = client.feed("user", fsTweet.user.id, fsTweet.user.feedToken); 30 | let timelineFeed = client.feed("timeline", fsTweet.user.id, fsTweet.user.timelineToken); 31 | 32 | userFeed.subscribe(function(data){ 33 | renderTweet($("#wall"),data.new[0]); 34 | }); 35 | timelineFeed.subscribe(function(data){ 36 | renderTweet($("#wall"),data.new[0]); 37 | }); 38 | 39 | timelineFeed.get({ 40 | limit: 25 41 | }).then(function(body) { 42 | var timelineTweets = body.results 43 | userFeed.get({ 44 | limit : 25 45 | }).then(function(body){ 46 | var userTweets = body.results 47 | var allTweets = $.merge(timelineTweets, userTweets) 48 | allTweets.sort(function(t1, t2){ 49 | return new Date(t2.time) - new Date(t1.time); 50 | }) 51 | $(allTweets.reverse()).each(function(index, tweet){ 52 | renderTweet($("#wall"), tweet); 53 | }); 54 | }) 55 | }) 56 | }); -------------------------------------------------------------------------------- /src/FsTweet.Web/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Suave 3 | DotLiquid 4 | Suave.DotLiquid 5 | Suave.Experimental 6 | Chessie 7 | BCrypt.Net-Next 8 | Chiron 9 | stream-net 10 | NodaTime 11 | Logary 12 | group Database 13 | SQLProvider 14 | Npgsql 15 | group Email 16 | Postmark -------------------------------------------------------------------------------- /src/FsTweet.Web/views/guest/home.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | FsTweet - Powered by F# 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 | 17 |

FsTweet

18 |
19 |
20 | 21 |

Communicate with the world in a different way!

22 |

23 | Sign up today 24 |

25 | Already a user? Sign in 26 |
27 |
28 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/master_page.liquid: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block head %} 11 | {% endblock %} 12 | 13 | 14 |
15 | {% block content %} 16 | {% endblock %} 17 |
18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | {% block scripts %} 28 | {% endblock %} 29 |
30 | 31 | -------------------------------------------------------------------------------- /src/FsTweet.Web/views/not_found.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Not Found :( 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

{{model}}

10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/server_error.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Internal Error :( 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

{{model}}

10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/login.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Login 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 | {% if model.Error %} 12 |

13 | {{ model.Error.Value }} 14 |

15 | {% endif %} 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 | 26 |
27 |
28 |
29 |
30 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/profile.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | {{model.Username}} - FsTweet 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 | 12 |

@{{model.Username}}

13 | {% if model.IsLoggedIn %} 14 | {% unless model.IsSelf %} 15 | {% if model.IsFollowing %} 16 | Following 17 | {% else %} 18 | Follow 19 | {% endif %} 20 | {% endunless %} 21 | Logout 22 | {% endif %} 23 |
24 |
25 |
26 | 37 | 38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {% endblock %} 51 | 52 | {% block scripts %} 53 | 54 | 55 | 68 | 69 | 70 | 71 | 72 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/signup.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Sign Up - FsTweet 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 27 |
28 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/signup_success.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Signup Success 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

10 | Hi {{ model }}, Your account has been created. 11 | Check your email to activate the account. 12 |

13 |
14 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/verification_success.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | Email Verified 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

10 | Hi {{ model }}, Your email address has been verified. 11 | Now you can login! 12 |

13 |
14 | {% endblock %} -------------------------------------------------------------------------------- /src/FsTweet.Web/views/user/wall.liquid: -------------------------------------------------------------------------------- 1 | {% extends "master_page.liquid" %} 2 | 3 | {% block head %} 4 | {{model.Username}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 |

Hi {{model.Username}}

12 | My Profile 13 | Logout 14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 | {% endblock %} 28 | 29 | 30 | {% block scripts %} 31 | 32 | 33 | 47 | 48 | 49 | 50 | {% endblock %} -------------------------------------------------------------------------------- /web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | --------------------------------------------------------------------------------