diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..71fc866 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,423 @@ +; EditorConfig to support per-solution formatting. +; Use the EditorConfig VS add-in to make this work. +; http://editorconfig.org/ +; +; Here are some resources for what's supported for .NET/C# +; https://kent-boogaart.com/blog/editorconfig-reference-for-c-developers +; https://learn.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference +; +; Be **careful** editing this because some of the rules don't support adding a severity level +; For instance if you change to `dotnet_sort_system_directives_first = true:warning` (adding `:warning`) +; then the rule will be silently ignored. + +; This is the default for the codeline. +root = true + +[*] +indent_style = space +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +indent_size = 4 +dotnet_sort_system_directives_first = true + +# Don't use this. qualifier +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion + +# use int x = .. over Int32 +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion + +# use int.MaxValue over Int32.MaxValue +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Require var all the time. +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Disallow throw expressions. +csharp_style_throw_expression = false:suggestion + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# Namespace settings +csharp_style_namespace_declarations = file_scoped + +# Brace settings +csharp_prefer_braces = true # Prefer curly braces even for one line of code + +[*.{xml,config,*proj,nuspec,props,resx,targets,yml,tasks}] +indent_size = 2 + +# Xml config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.{ps1,psm1}] +indent_size = 4 + +[*.sh] +indent_size = 4 +end_of_line = lf + +[*.{razor,cshtml}] +charset = utf-8-bom + +[*.{cs,vb}] + +# SYSLIB1054: Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time +dotnet_diagnostic.SYSLIB1054.severity = warning + +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = warning + +# CA1047: Do not declare protected member in sealed type +dotnet_diagnostic.CA1047.severity = warning + +# CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = warning + +# CA1507: Use nameof to express symbol names +dotnet_diagnostic.CA1507.severity = warning + +# CA1510: Use ArgumentNullException throw helper +dotnet_diagnostic.CA1510.severity = warning + +# CA1511: Use ArgumentException throw helper +dotnet_diagnostic.CA1511.severity = warning + +# CA1512: Use ArgumentOutOfRangeException throw helper +dotnet_diagnostic.CA1512.severity = warning + +# CA1513: Use ObjectDisposedException throw helper +dotnet_diagnostic.CA1513.severity = warning + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = suggestion + +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = warning + +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = warning + +# CA1810: Do not initialize unnecessarily +dotnet_diagnostic.CA1810.severity = warning + +# CA1821: Remove empty Finalizers +dotnet_diagnostic.CA1821.severity = warning + +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = warning +dotnet_code_quality.CA1822.api_surface = private, internal + +# CA1823: Avoid unused private fields +dotnet_diagnostic.CA1823.severity = warning + +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = warning + +# CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1826.severity = warning + +# CA1827: Do not use Count() or LongCount() when Any() can be used +dotnet_diagnostic.CA1827.severity = warning + +# CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used +dotnet_diagnostic.CA1828.severity = warning + +# CA1829: Use Length/Count property instead of Count() when available +dotnet_diagnostic.CA1829.severity = warning + +# CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder +dotnet_diagnostic.CA1830.severity = warning + +# CA1831: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +# CA1832: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +# CA1833: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1831.severity = warning +dotnet_diagnostic.CA1832.severity = warning +dotnet_diagnostic.CA1833.severity = warning + +# CA1834: Consider using 'StringBuilder.Append(char)' when applicable +dotnet_diagnostic.CA1834.severity = warning + +# CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' +dotnet_diagnostic.CA1835.severity = warning + +# CA1836: Prefer IsEmpty over Count +dotnet_diagnostic.CA1836.severity = warning + +# CA1837: Use 'Environment.ProcessId' +dotnet_diagnostic.CA1837.severity = warning + +# CA1838: Avoid 'StringBuilder' parameters for P/Invokes +dotnet_diagnostic.CA1838.severity = warning + +# CA1839: Use 'Environment.ProcessPath' +dotnet_diagnostic.CA1839.severity = warning + +# CA1840: Use 'Environment.CurrentManagedThreadId' +dotnet_diagnostic.CA1840.severity = warning + +# CA1841: Prefer Dictionary.Contains methods +dotnet_diagnostic.CA1841.severity = warning + +# CA1842: Do not use 'WhenAll' with a single task +dotnet_diagnostic.CA1842.severity = warning + +# CA1843: Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1843.severity = warning + +# CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' +dotnet_diagnostic.CA1844.severity = warning + +# CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = warning + +# CA1846: Prefer AsSpan over Substring +dotnet_diagnostic.CA1846.severity = warning + +# CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters +dotnet_diagnostic.CA1847.severity = warning + +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = warning + +# CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1854.severity = warning + +# CA1855: Prefer 'Clear' over 'Fill' +dotnet_diagnostic.CA1855.severity = warning + +# CA1856: Incorrect usage of ConstantExpected attribute +dotnet_diagnostic.CA1856.severity = error + +# CA1857: A constant is expected for the parameter +dotnet_diagnostic.CA1857.severity = warning + +# CA1858: Use 'StartsWith' instead of 'IndexOf' +dotnet_diagnostic.CA1858.severity = warning + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = warning + +# CA2008: Do not create tasks without passing a TaskScheduler +dotnet_diagnostic.CA2008.severity = warning + +# CA2009: Do not call ToImmutableCollection on an ImmutableCollection value +dotnet_diagnostic.CA2009.severity = warning + +# CA2011: Avoid infinite recursion +dotnet_diagnostic.CA2011.severity = warning + +# CA2012: Use ValueTask correctly +dotnet_diagnostic.CA2012.severity = warning + +# CA2013: Do not use ReferenceEquals with value types +dotnet_diagnostic.CA2013.severity = warning + +# CA2014: Do not use stackalloc in loops. +dotnet_diagnostic.CA2014.severity = warning + +# CA2016: Forward the 'CancellationToken' parameter to methods that take one +dotnet_diagnostic.CA2016.severity = warning + +# CA2200: Rethrow to preserve stack details +dotnet_diagnostic.CA2200.severity = warning + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = warning + +# CA2245: Do not assign a property to itself +dotnet_diagnostic.CA2245.severity = warning + +# CA2246: Assigning symbol and its member in the same statement +dotnet_diagnostic.CA2246.severity = warning + +# CA2249: Use string.Contains instead of string.IndexOf to improve readability. +dotnet_diagnostic.CA2249.severity = warning + +# IDE0005: Remove unnecessary usings +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0020: Use pattern matching to avoid is check followed by a cast (with variable) +dotnet_diagnostic.IDE0020.severity = warning + +# IDE0029: Use coalesce expression (non-nullable types) +dotnet_diagnostic.IDE0029.severity = warning + +# IDE0030: Use coalesce expression (nullable types) +dotnet_diagnostic.IDE0030.severity = warning + +# IDE0031: Use null propagation +dotnet_diagnostic.IDE0031.severity = warning + +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning + +# IDE0036: Order modifiers +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0038: Use pattern matching to avoid is check followed by a cast (without variable) +dotnet_diagnostic.IDE0038.severity = warning + +# IDE0043: Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = warning + +# IDE0051: Remove unused private members +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0055: All formatting rules +dotnet_diagnostic.IDE0055.severity = suggestion + +# IDE0059: Unnecessary assignment to a value +dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_code_quality_unused_parameters = non_public +dotnet_diagnostic.IDE0060.severity = warning + +# IDE0062: Make local function static +dotnet_diagnostic.IDE0062.severity = warning + +# IDE0161: Convert to file-scoped namespace +dotnet_diagnostic.IDE0161.severity = warning + +# IDE0200: Lambda expression can be removed +dotnet_diagnostic.IDE0200.severity = warning + +# IDE2000: Disallow multiple blank lines +dotnet_style_allow_multiple_blank_lines_experimental = false +dotnet_diagnostic.IDE2000.severity = warning + +[{eng/tools/**.cs,**/{test,testassets,samples,Samples,perf,scripts,stress}/**.cs}] +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = suggestion +# CA1507: Use nameof to express symbol names +dotnet_diagnostic.CA1507.severity = suggestion +# CA1510: Use ArgumentNullException throw helper +dotnet_diagnostic.CA1510.severity = suggestion +# CA1511: Use ArgumentException throw helper +dotnet_diagnostic.CA1511.severity = suggestion +# CA1512: Use ArgumentOutOfRangeException throw helper +dotnet_diagnostic.CA1512.severity = suggestion +# CA1513: Use ObjectDisposedException throw helper +dotnet_diagnostic.CA1513.severity = suggestion +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = suggestion +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = suggestion +# CA1810: Do not initialize unnecessarily +dotnet_diagnostic.CA1810.severity = suggestion +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = suggestion +# CA1823: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = suggestion +# CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1826.severity = suggestion +# CA1827: Do not use Count() or LongCount() when Any() can be used +dotnet_diagnostic.CA1827.severity = suggestion +# CA1829: Use Length/Count property instead of Count() when available +dotnet_diagnostic.CA1829.severity = suggestion +# CA1834: Consider using 'StringBuilder.Append(char)' when applicable +dotnet_diagnostic.CA1834.severity = suggestion +# CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' +dotnet_diagnostic.CA1835.severity = suggestion +# CA1837: Use 'Environment.ProcessId' +dotnet_diagnostic.CA1837.severity = suggestion +# CA1838: Avoid 'StringBuilder' parameters for P/Invokes +dotnet_diagnostic.CA1838.severity = suggestion +# CA1841: Prefer Dictionary.Contains methods +dotnet_diagnostic.CA1841.severity = suggestion +# CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' +dotnet_diagnostic.CA1844.severity = suggestion +# CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = suggestion +# CA1846: Prefer AsSpan over Substring +dotnet_diagnostic.CA1846.severity = suggestion +# CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters +dotnet_diagnostic.CA1847.severity = suggestion +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = suggestion +# CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1854.severity = suggestion +# CA1855: Prefer 'Clear' over 'Fill' +dotnet_diagnostic.CA1855.severity = suggestion +# CA1856: Incorrect usage of ConstantExpected attribute +dotnet_diagnostic.CA1856.severity = suggestion +# CA1857: A constant is expected for the parameter +dotnet_diagnostic.CA1857.severity = suggestion +# CA1858: Use 'StartsWith' instead of 'IndexOf' +dotnet_diagnostic.CA1858.severity = suggestion +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = suggestion +# CA2008: Do not create tasks without passing a TaskScheduler +dotnet_diagnostic.CA2008.severity = suggestion +# CA2012: Use ValueTask correctly +dotnet_diagnostic.CA2012.severity = suggestion +# CA2249: Use string.Contains instead of string.IndexOf to improve readability. +dotnet_diagnostic.CA2249.severity = suggestion +# IDE0005: Remove unnecessary usings +dotnet_diagnostic.IDE0005.severity = suggestion +# IDE0020: Use pattern matching to avoid is check followed by a cast (with variable) +dotnet_diagnostic.IDE0020.severity = suggestion +# IDE0029: Use coalesce expression (non-nullable types) +dotnet_diagnostic.IDE0029.severity = suggestion +# IDE0030: Use coalesce expression (nullable types) +dotnet_diagnostic.IDE0030.severity = suggestion +# IDE0031: Use null propagation +dotnet_diagnostic.IDE0031.severity = suggestion +# IDE0038: Use pattern matching to avoid is check followed by a cast (without variable) +dotnet_diagnostic.IDE0038.severity = suggestion +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = suggestion +# IDE0051: Remove unused private members +dotnet_diagnostic.IDE0051.severity = suggestion +# IDE0059: Unnecessary assignment to a value +dotnet_diagnostic.IDE0059.severity = suggestion +# IDE0060: Remove unused parameters +dotnet_diagnostic.IDE0060.severity = suggestion +# IDE0062: Make local function static +dotnet_diagnostic.IDE0062.severity = suggestion +# IDE0200: Lambda expression can be removed +dotnet_diagnostic.IDE0200.severity = suggestion + +# CA2016: Forward the 'CancellationToken' parameter to methods that take one +dotnet_diagnostic.CA2016.severity = suggestion + +# Defaults for content in the shared src/ and shared runtime dir + +[{**/Shared/runtime/**.{cs,vb},src/Shared/test/Shared.Tests/runtime/**.{cs,vb},**/microsoft.extensions.hostfactoryresolver.sources/**.{cs,vb}}] +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = silent +# IDE0011: Use braces +dotnet_diagnostic.IDE0011.severity = silent +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = silent +# IDE0060: Remove unused parameters +dotnet_diagnostic.IDE0060.severity = silent +# IDE0062: Make local function static +dotnet_diagnostic.IDE0062.severity = silent +# IDE0161: Convert to file-scoped namespace +dotnet_diagnostic.IDE0161.severity = silent + +[{**/Shared/**.cs,**/microsoft.extensions.hostfactoryresolver.sources/**.{cs,vb}}] +# IDE0005: Remove unused usings. Ignore for shared src files since imports for those depend on the projects in which they are included. +dotnet_diagnostic.IDE0005.severity = silent diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a1e1e97 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..db4d0a6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "12:00" + timezone: "America/New_York" + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + day: monday + time: "12:00" + timezone: "America/New_York" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d75759e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + target: [net6.0, net7.0] + runs-on: self-hosted + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 7.0.x + + - name: Restore test dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c release -f ${{ matrix.target }} --no-restore diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..b0d7c5a --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: "40 7 * * 4" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["csharp"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4658389 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release Package + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + target: [net6.0, net7.0] + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 7.0.x + + - name: Restore test dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c release -f ${{ matrix.target }} --no-restore + + publish: + runs-on: self-hosted + needs: build + + steps: + - name: Build NuGet Package + run: dotnet pack -c Release -p:PackageVersion=${{ github.ref_name }} + + - name: Upload nuget package artifact + uses: actions/upload-artifact@v3 + with: + name: Nuget package + path: "**/*.nupkg" + + - name: Publish NuGet Package + run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_TOKEN }} -s https://api.nuget.org/v3/index.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c4efe2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,261 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Configuration/KamiOptions.cs b/Configuration/KamiOptions.cs new file mode 100644 index 0000000..2ea13b6 --- /dev/null +++ b/Configuration/KamiOptions.cs @@ -0,0 +1,32 @@ +namespace OnCourse.Kami.Configuration; + +public class KamiOptions +{ + public const string SectionName = "Kami"; + + public string? Token { get; set; } + public string BaseAddress { get; set; } = "https://api.notablepdf.com/"; + public List AllowedExtensions { get; set; } = new List() + { + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "pdf", + "odt", + "odp", + "ods", + "txt", + "rtf", + "gdoc", + "gsheet", + "jpg", + "jpeg", + "gif", + "png", + "tif", + "tiff" + }; +} diff --git a/Kami.Sdk.csproj b/Kami.Sdk.csproj new file mode 100644 index 0000000..133e2e7 --- /dev/null +++ b/Kami.Sdk.csproj @@ -0,0 +1,39 @@ + + + net6.0;net7.0 + enable + enable + latest + + + OnCourse.Kami + OnCourse Systems For Education + OnCourse Systems For Education + Kami SDK for .NET + Kami API Client + https://github.com/oncoursesystems/kami-sdk + https://github.com/oncoursesystems/kami-sdk + git + true + Copyright (c) 2023 OnCourse Systems For Education + https://licenses.nuget.org/MIT + MIT + kami + README.md + kami.png + + + + + + + + + + + + + + + + diff --git a/KamiClient.cs b/KamiClient.cs new file mode 100644 index 0000000..fe806cc --- /dev/null +++ b/KamiClient.cs @@ -0,0 +1,251 @@ +using System.Text; +using OnCourse.Kami.Configuration; +using OnCourse.Kami.Model; +using OnCourse.Kami.Serialization; +using Newtonsoft.Json; +using Microsoft.Extensions.Options; +using System.Globalization; + +namespace OnCourse.Kami; + +public interface IKamiClient +{ + Task UploadFile(byte[] file, string contentType, string fileName); + Task DeleteFile(string documentIdentifier); + Task CreateViewSession(string documentIdentifier, string userName, string userId, DateTime? expiresAt = null, KamiViewerOptions? viewerOptions = null, bool editable = true); + Task ExportFile(string documentIdentifier, string exportType = "inline"); +} + +public class KamiClient : IKamiClient +{ + private readonly HttpClient _httpClient; + private readonly KamiOptions _kamiOptions; + + public KamiClient(HttpClient httpClient, IOptions kamiOptions) + { + _httpClient = httpClient; + _kamiOptions = kamiOptions.Value; + } + + private bool CheckFileType(string fileName) + { + var ext = Path.GetExtension(fileName); + + if (string.IsNullOrEmpty(ext)) + return false; + + if (ext[0] == '.') + ext = ext[1..]; + + return _kamiOptions.AllowedExtensions.Contains(ext.ToLower()); + } + + public async Task UploadFile(byte[] file, string contentType, string fileName) + { + const string boundary = "-----BOUNDARY"; + + if (!CheckFileType(fileName)) + { + return new KamiUploadResult + { + Success = false, + Message = "File type is not supported" + }; + } + + using var multiPartContent = new MultipartFormDataContent(boundary); + var fileNameContent = new StringContent(fileName); + fileNameContent.Headers.TryAddWithoutValidation("Content-Disposition", "form-data; name=\"name\""); + multiPartContent.Add(fileNameContent); + + var documentContent = new ByteArrayContent(file); + documentContent.Headers.TryAddWithoutValidation("Content-Disposition", $"form-data; name=\"document\"; filename=\"{fileName}\""); + documentContent.Headers.TryAddWithoutValidation("Content-Type", contentType); + multiPartContent.Add(documentContent); + + var response = await _httpClient.PostAsync("upload/embed/documents", multiPartContent).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return new KamiUploadResult + { + Success = false, + Message = response.ReasonPhrase + }; + } + + try + { + var data = response.Content.ReadAsStringAsync().Result; + return JsonConvert.DeserializeObject(data, JsonSerialization.GetDefaultSerializerSettings()) ?? new KamiUploadResult + { + Success = false, + Message = "Could not deserialize upload result" + }; + } + catch (Exception ex) + { + return new KamiUploadResult + { + Success = false, + Message = ex.Message + }; + } + } + + public async Task DeleteFile(string documentIdentifier) + { + using var response = await _httpClient.DeleteAsync("embed/documents/" + documentIdentifier).ConfigureAwait(false); + return new KamiDeleteResult + { + Success = response.IsSuccessStatusCode, + Message = response.ReasonPhrase + }; + } + + public async Task CreateViewSession(string documentIdentifier, string userName, string userId, DateTime? expiresAt = null, KamiViewerOptions? viewerOptions = null, bool editable = true) + { + var expirationDate = (expiresAt ?? DateTime.Now.AddYears(1)).ToString(CultureInfo.InvariantCulture); + var requestJson = JsonConvert.SerializeObject(new + { + DocumentIdentifier = documentIdentifier, + User = new + { + Name = userName, + UserId = userId + }, + ExpiresAt = expirationDate, + ViewerOptions = viewerOptions ?? new KamiViewerOptions(), + Editable = editable + }, JsonSerialization.GetDefaultSerializerSettings()); + + var content = new StringContent(requestJson, Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync("embed/sessions", content).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return new KamiCreateViewSessionResult + { + Success = false, + Message = response.ReasonPhrase + }; + } + + try + { + var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var sessionResult = JsonConvert.DeserializeObject(data, JsonSerialization.GetDefaultSerializerSettings()); + + if (sessionResult == null) + { + return new KamiCreateViewSessionResult + { + Success = false, + Message = "Could not deserialize create view session result" + }; + } + + sessionResult.ExpirationDate = expirationDate; + return sessionResult; + } + catch (Exception ex) + { + return new KamiCreateViewSessionResult + { + Success = false, + Message = ex.Message + }; + } + } + + public async Task ExportFile(string documentIdentifier, string exportType = "inline") + { + var result = await CreateDocumentExport(documentIdentifier, exportType).ConfigureAwait(false); + + while (result?.Status == "pending") + { + result = await GetDocumentExport(result.Id).ConfigureAwait(false); + } + + if (result?.Status == "done") + { + using var client = new HttpClient(); + result.FileBytes = await client.GetByteArrayAsync(result.FileUrl).ConfigureAwait(false); + } + + return result ?? new KamiDocumentExportResult + { + Status = "error", + ErrorType = "Unable to create document export" + }; + } + + private async Task CreateDocumentExport(string documentIdentifier, string exportType) + { + var requestJson = JsonConvert.SerializeObject(new + { + DocumentIdentifier = documentIdentifier, + ExportType = exportType + }, JsonSerialization.GetDefaultSerializerSettings()); + + using var content = new StringContent(requestJson, Encoding.Default, "application/json"); + using var response = await _httpClient.PostAsync("embed/exports", content).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return new KamiDocumentExportResult + { + Status = "error", + ErrorType = response.ReasonPhrase + }; + } + + try + { + var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(data, JsonSerialization.GetDefaultSerializerSettings()) ?? new KamiDocumentExportResult + { + Status = "error", + ErrorType = "Could not deserialize document export result" + }; + } + catch (Exception ex) + { + return new KamiDocumentExportResult + { + Status = "error", + ErrorType = ex.Message + }; + } + } + + private async Task GetDocumentExport(string exportId) + { + using var response = await _httpClient.GetAsync("embed/exports/" + exportId).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return new KamiDocumentExportResult + { + Status = "error", + ErrorType = response.ReasonPhrase + }; + } + + try + { + var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(data, JsonSerialization.GetDefaultSerializerSettings()) ?? new KamiDocumentExportResult + { + Status = "error", + ErrorType = "Could not deserialize document export result" + }; + } + catch (Exception ex) + { + return new KamiDocumentExportResult + { + Status = "error", + ErrorType = ex.Message + }; + } + } +} diff --git a/Model/KamiCreateViewSessionResult.cs b/Model/KamiCreateViewSessionResult.cs new file mode 100644 index 0000000..93b0566 --- /dev/null +++ b/Model/KamiCreateViewSessionResult.cs @@ -0,0 +1,9 @@ +namespace OnCourse.Kami.Model; + +public class KamiCreateViewSessionResult +{ + public string? ViewerUrl { get; set; } + public bool Success { get; set; } = true; + public string? Message { get; set; } + public string? ExpirationDate { get; set; } +} diff --git a/Model/KamiDeleteResult.cs b/Model/KamiDeleteResult.cs new file mode 100644 index 0000000..63ee019 --- /dev/null +++ b/Model/KamiDeleteResult.cs @@ -0,0 +1,7 @@ +namespace OnCourse.Kami.Model; + +public class KamiDeleteResult +{ + public bool Success { get; set; } = true; + public string? Message { get; set; } +} diff --git a/Model/KamiDocumentExportResult.cs b/Model/KamiDocumentExportResult.cs new file mode 100644 index 0000000..60b9e93 --- /dev/null +++ b/Model/KamiDocumentExportResult.cs @@ -0,0 +1,10 @@ +namespace OnCourse.Kami.Model; + +public class KamiDocumentExportResult +{ + public string Id { get; set; } = ""; + public string? Status { get; set; } + public string? FileUrl { get; set; } + public string? ErrorType { get; set; } + public byte[]? FileBytes { get; set; } +} diff --git a/Model/KamiUploadResult.cs b/Model/KamiUploadResult.cs new file mode 100644 index 0000000..79d2eb5 --- /dev/null +++ b/Model/KamiUploadResult.cs @@ -0,0 +1,11 @@ +namespace OnCourse.Kami.Model; + +public class KamiUploadResult +{ + public string? Name { get; set; } + public DateTime CreatedAt { get; set; } + public string? FileStatus { get; set; } + public string? DocumentIdentifier { get; set; } + public bool Success { get; set; } = true; + public string? Message { get; set; } +} diff --git a/Model/KamiViewerOptions.cs b/Model/KamiViewerOptions.cs new file mode 100644 index 0000000..30c58b2 --- /dev/null +++ b/Model/KamiViewerOptions.cs @@ -0,0 +1,44 @@ +namespace OnCourse.Kami.Model; + +public class KamiViewerOptions +{ + public string Theme { get; set; } = "dark"; + public bool ShowSave { get; set; } = true; + public bool ShowPrint { get; set; } = true; + public bool ShowHelp { get; set; } = true; + public bool ShowMenu { get; set; } = true; + public KamiToolVisibility ToolVisibility { get; set; } = new KamiToolVisibility(); +} + +public class KamiViewerMobileOptions : KamiViewerOptions +{ + public KamiViewerMobileOptions() + { + ShowPrint = false; + ShowMenu = false; + ShowHelp = false; + ToolVisibility = new KamiToolVisibility + { + Equation = false, + Comment = false, + Autograph = false + }; + } +} + +public class KamiToolVisibility +{ + public bool Normal { get; set; } = true; + public bool Highlight { get; set; } = true; + public bool Strikethrough { get; set; } = true; + public bool Underline { get; set; } = true; + public bool Comment { get; set; } = true; + public bool Text { get; set; } = true; + public bool Equation { get; set; } = true; + public bool Drawing { get; set; } = true; + public bool Shape { get; set; } = true; + public bool Eraser { get; set; } = true; + public bool Image { get; set; } = true; + public bool Autograph { get; set; } = true; + public bool Tts { get; set; } = true; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f9a96e --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +![Kami logo](https://raw.githubusercontent.com/oncoursesystems/kami-sdk/master/kami.png) + +# OnCourse.Kami + +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![Build Status](https://github.com/oncoursesystems/kami-sdk/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/oncoursesystems/kami-sdk/actions/workflows/ci.yml) +[![NuGet Version](https://img.shields.io/nuget/v/OnCourse.Kami)](https://www.nuget.org/packages/OnCourse.Kami/) + +### OnCourse.Kami is a .NET SDK library used to communicate with the [Kami API](https://kamiembeddingapi.docs.apiary.io/) + +## ✔ Features + +Kami API library helps to generate requests for the following services: + +- Embedding + - Uploads + - Documents + - View Sessions +- Exporting + +## ⭐ Installation + +This project is a class library built for compatibility all the back to .NET Standard 2.0. + +To install the OnCourse.Kami NuGet package, run the following command via the dotnet CLI + +``` +dotnet add package OnCourse.Kami +``` + +Or run the following command in the Package Manager Console of Visual Studio + +``` +PM> Install-Package OnCourse.Kami +``` + +## 📕 General Usage + +### Initialization + +To use Kami, import the namespace and include the .AddKami() method when initializing the host builder (typically +found in the Program.cs file) + +```csharp +using Kami; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddKamiClient(builder.Configuration); + +... + +var app = builder.Build(); + +``` + +### Fault Handling / Resilience + +By default, the client will be configured to retry a call up to three times with increasing waits between (1s, 5s, 10s). If after the third call the service still returns an error then the call will be considered failed. You can override this policy during the UseKami method by passing in a policy as the second parameter. It is recommended to use [Polly](https://github.com/App-vNext/Polly), a 3rd-party library, that has a lot of options for creating policies + +```csharp + +builder.Services.AddKamiClient(builder.Configuration, (p => p.WaitAndRetryAsync(new[] +{ + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10) +})); + +``` + +### Configuration + +Additional configuration can be done in the appSettings.config file within the "Kami" section. The default settings are shown here and can be overridden if needed: + +```json +{ + "Kami": { + "Token": "Token #####################", + "BaseAddress": "https://api.notablepdf.com/", + "AllowedExtensions": [ + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "pdf", + "odt", + "odp", + "ods", + "txt", + "rtf", + "gdoc", + "gsheet", + "jpg", + "jpeg", + "gif", + "png", + "tif", + "tiff" + ] + } +} +``` + +## 🚀 Example + +After initializing with the UseKami() method above, the client will be registered in the DI system. You can inject the client in the constructor of any class that needs to use it. + +```csharp +public class TestClass +{ + private readonly IKamiClient kamiClient + + public TestClass(IKamiClient kamiClient) + { + this.kamiClient = kamiClient; + } + + public async Task UploadDocument(int fileId) + { + var (bytes, mimeType, fileName) = await this.GetFile(fileId); + return await this.kamiClient.UploadFile(bytes, mimeType, fileName); + } + + public async Task DeleteDocument(string kamiDocumentId) + { + await this.kamiClient.DeleteFile(kamiDocumentId); + } + + public async Task CreateViewSession(string kamiDocumentId) + { + var (username, userId) = await this.GetUser(); + + // optional settings + var expiresAt = DateTime.Now.AddDays(7); + var viewerOptions = new KamiViewerOptions(); + // var mobileViewerOptions = KamiViewerOptions.Mobile; + var editable = true; + + return await this.kamiClient.CreateViewSession(kamiDocumentId, username, userId, expiresAt, viewerOptions, editable); + } + + public async Task ExportDocument(kamiDocumentId) + { + // export type is optional, defaults to "inline". See Kami API documentation site for more options and what they do + var exportType = "inline"; + + return await this.kamiClient.ExportFile(kamiDocumentId, exportType); + } +} +``` diff --git a/Serialization/JsonSerialization.cs b/Serialization/JsonSerialization.cs new file mode 100644 index 0000000..b7f54ef --- /dev/null +++ b/Serialization/JsonSerialization.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace OnCourse.Kami.Serialization; + +public static class JsonSerialization +{ + public static JsonSerializerSettings GetDefaultSerializerSettings() + { + var converters = new JsonConverterCollection + { + new StringEnumConverter() + }; + + var settings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + Converters = converters, + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } + }; + + return settings; + } +} diff --git a/ServiceCollectionExtensions.cs b/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d59ed82 --- /dev/null +++ b/ServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using OnCourse.Kami.Configuration; +using Polly; +using OnCourse.Kami; +using Ardalis.GuardClauses; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddKamiClient(this IServiceCollection services, IConfiguration configuration) + { + return services.AddKamiClient(configuration, null); + } + + public static IServiceCollection AddKamiClient(this IServiceCollection services, IConfiguration configuration, Func, IAsyncPolicy>? errorPolicy = null) + { + services.Configure(configuration.GetSection(KamiOptions.SectionName)); + + var address = configuration["Kami:BaseAddress"]; + var token = configuration["Kami:Token"]; + + Guard.Against.NullOrEmpty(address, "Kami:BassAddress", "Missing Kami BaseAddress in settings."); + Guard.Against.NullOrEmpty(token, "Kami:Token", "Missing Kami Token in settings."); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.DefaultRequestHeaders.TryAddWithoutValidation("authorization", token); + }) + .AddTransientHttpErrorPolicy(errorPolicy ?? (p => p.WaitAndRetryAsync(new[] + { + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10) + }))); + + return services; + } +} diff --git a/kami.png b/kami.png new file mode 100644 index 0000000..9dd46a5 Binary files /dev/null and b/kami.png differ diff --git a/oncourse.jpg b/oncourse.jpg new file mode 100644 index 0000000..aa82a15 Binary files /dev/null and b/oncourse.jpg differ